From 54751a715c689cfea7a9060999f058c63c36e1e5 Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Thu, 12 Aug 2021 16:44:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20=E9=A3=9E=E4=B9=A6?= =?UTF-8?q?=20(#6602)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 添加 飞书 Co-authored-by: xinwen Co-authored-by: wenyann <64353056+wenyann@users.noreply.github.com> --- apps/authentication/api/__init__.py | 1 + apps/authentication/api/feishu.py | 45 ++ apps/authentication/backends/api.py | 9 + apps/authentication/errors.py | 5 + .../templates/authentication/login.html | 7 +- apps/authentication/urls/api_urls.py | 4 + apps/authentication/urls/view_urls.py | 8 + apps/authentication/views/__init__.py | 1 + apps/authentication/views/feishu.py | 253 ++++++++ apps/authentication/views/login.py | 1 + .../message/backends/dingtalk/__init__.py | 39 +- .../message/backends/feishu/__init__.py | 114 ++++ apps/common/message/backends/mixin.py | 121 +++- apps/common/message/backends/utils.py | 2 +- .../common/message/backends/wecom/__init__.py | 42 +- apps/jumpserver/conf.py | 4 + apps/jumpserver/context_processor.py | 1 + apps/jumpserver/settings/auth.py | 7 +- apps/locale/zh/LC_MESSAGES/django.mo | Bin 81401 -> 81793 bytes apps/locale/zh/LC_MESSAGES/django.po | 564 +++++++++--------- apps/notifications/backends/__init__.py | 5 +- apps/notifications/backends/dingtalk.py | 1 - apps/notifications/backends/feishu.py | 19 + apps/settings/api/__init__.py | 1 + apps/settings/api/common.py | 2 + apps/settings/api/feishu.py | 41 ++ apps/settings/serializers/settings.py | 8 + apps/settings/urls/api_urls.py | 1 + apps/static/img/login_feishu_logo.png | Bin 0 -> 6640 bytes apps/users/migrations/0036_user_feishu_id.py | 18 + apps/users/models/user.py | 5 + apps/users/serializers/user.py | 2 +- 32 files changed, 975 insertions(+), 356 deletions(-) create mode 100644 apps/authentication/api/feishu.py create mode 100644 apps/authentication/views/feishu.py create mode 100644 apps/common/message/backends/feishu/__init__.py create mode 100644 apps/notifications/backends/feishu.py create mode 100644 apps/settings/api/feishu.py create mode 100644 apps/static/img/login_feishu_logo.png create mode 100644 apps/users/migrations/0036_user_feishu_id.py diff --git a/apps/authentication/api/__init__.py b/apps/authentication/api/__init__.py index 6ef54b09b..c0064f9bd 100644 --- a/apps/authentication/api/__init__.py +++ b/apps/authentication/api/__init__.py @@ -9,4 +9,5 @@ from .login_confirm import * from .sso import * from .wecom import * from .dingtalk import * +from .feishu import * from .password import * diff --git a/apps/authentication/api/feishu.py b/apps/authentication/api/feishu.py new file mode 100644 index 000000000..1665d057a --- /dev/null +++ b/apps/authentication/api/feishu.py @@ -0,0 +1,45 @@ +from rest_framework.views import APIView +from rest_framework.request import Request +from rest_framework.response import Response + +from users.permissions import IsAuthPasswdTimeValid +from users.models import User +from common.utils import get_logger +from common.permissions import IsOrgAdmin +from common.mixins.api import RoleUserMixin, RoleAdminMixin +from authentication import errors + +logger = get_logger(__file__) + + +class FeiShuQRUnBindBase(APIView): + user: User + + def post(self, request: Request, **kwargs): + user = self.user + + if not user.feishu_id: + raise errors.FeiShuNotBound + + user.feishu_id = None + user.save() + return Response() + + +class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase): + permission_classes = (IsAuthPasswdTimeValid,) + + +class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase): + user_id_url_kwarg = 'user_id' + permission_classes = (IsOrgAdmin,) + + +class FeiShuEventSubscriptionCallback(APIView): + """ + # https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM + """ + permission_classes = () + + def post(self, request: Request, *args, **kwargs): + return Response(data=request.data) diff --git a/apps/authentication/backends/api.py b/apps/authentication/backends/api.py index 892ebcc7c..79d420626 100644 --- a/apps/authentication/backends/api.py +++ b/apps/authentication/backends/api.py @@ -240,6 +240,15 @@ class DingTalkAuthentication(JMSModelBackend): pass +class FeiShuAuthentication(JMSModelBackend): + """ + 什么也不做呀😺 + """ + + def authenticate(self, request, **kwargs): + pass + + class AuthorizationTokenAuthentication(JMSModelBackend): """ 什么也不做呀😺 diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index bcd83c97d..ad8148182 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -315,6 +315,11 @@ class DingTalkNotBound(JMSException): default_detail = 'DingTalk is not bound' +class FeiShuNotBound(JMSException): + default_code = 'feishu_not_bound' + default_detail = 'FeiShu is not bound' + + class PasswdInvalid(JMSException): default_code = 'passwd_invalid' default_detail = _('Your password is invalid') diff --git a/apps/authentication/templates/authentication/login.html b/apps/authentication/templates/authentication/login.html index 0104d0e44..c54f792c7 100644 --- a/apps/authentication/templates/authentication/login.html +++ b/apps/authentication/templates/authentication/login.html @@ -191,7 +191,7 @@
- {% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK %} + {% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK or AUTH_FEISHU %}
{% trans "More login options" %} @@ -215,6 +215,11 @@ {% trans 'DingTalk' %} {% endif %} + {% if AUTH_FEISHU %} + + {% endif %}
{% else %} diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index 0849cc82a..d8613adf4 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -20,6 +20,10 @@ urlpatterns = [ path('dingtalk/qr/unbind/', api.DingTalkQRUnBindForUserApi.as_view(), name='dingtalk-qr-unbind'), path('dingtalk/qr/unbind//', api.DingTalkQRUnBindForAdminApi.as_view(), name='dingtalk-qr-unbind-for-admin'), + path('feishu/qr/unbind/', api.FeiShuQRUnBindForUserApi.as_view(), name='feishu-qr-unbind'), + path('feishu/qr/unbind//', api.FeiShuQRUnBindForAdminApi.as_view(), name='feishu-qr-unbind-for-admin'), + path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(), name='feishu-event-subscription-callback'), + path('auth/', api.TokenCreateApi.as_view(), name='user-auth'), path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'), path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'), diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index 8e754340c..0bac07e25 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -37,6 +37,14 @@ urlpatterns = [ path('dingtalk/qr/bind//callback/', views.DingTalkQRBindCallbackView.as_view(), name='dingtalk-qr-bind-callback'), path('dingtalk/qr/login/callback/', views.DingTalkQRLoginCallbackView.as_view(), name='dingtalk-qr-login-callback'), + path('feishu/bind/success-flash-msg/', views.FlashDingTalkBindSucceedMsgView.as_view(), name='feishu-bind-success-flash-msg'), + path('feishu/bind/failed-flash-msg/', views.FlashDingTalkBindFailedMsgView.as_view(), name='feishu-bind-failed-flash-msg'), + path('feishu/bind/start/', views.FeiShuEnableStartView.as_view(), name='feishu-bind-start'), + path('feishu/qr/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'), + path('feishu/qr/login/', views.FeiShuQRLoginView.as_view(), name='feishu-qr-login'), + path('feishu/qr/bind/callback/', views.FeiShuQRBindCallbackView.as_view(), name='feishu-qr-bind-callback'), + path('feishu/qr/login/callback/', views.FeiShuQRLoginCallbackView.as_view(), name='feishu-qr-login-callback'), + # Profile path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'), path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'), diff --git a/apps/authentication/views/__init__.py b/apps/authentication/views/__init__.py index 0467e321a..38cf114e1 100644 --- a/apps/authentication/views/__init__.py +++ b/apps/authentication/views/__init__.py @@ -4,3 +4,4 @@ from .login import * from .mfa import * from .wecom import * from .dingtalk import * +from .feishu import * diff --git a/apps/authentication/views/feishu.py b/apps/authentication/views/feishu.py new file mode 100644 index 000000000..2db1404d7 --- /dev/null +++ b/apps/authentication/views/feishu.py @@ -0,0 +1,253 @@ +import urllib + +from django.http.response import HttpResponseRedirect, HttpResponse +from django.utils.decorators import method_decorator +from django.utils.translation import ugettext_lazy as _ +from django.views.decorators.cache import never_cache +from django.views.generic import TemplateView +from django.views import View +from django.conf import settings +from django.http.request import HttpRequest +from django.db.utils import IntegrityError +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.exceptions import APIException + +from users.utils import is_auth_password_time_valid +from users.views import UserVerifyPasswordView +from users.models import User +from common.utils import get_logger +from common.utils.random import random_string +from common.utils.django import reverse, get_object_or_none +from common.mixins.views import PermissionsMixin +from common.message.backends.feishu import FeiShu, URL +from authentication import errors +from authentication.mixins import AuthMixin + +logger = get_logger(__file__) + + +FEISHU_STATE_SESSION_KEY = '_feishu_state' + + +class FeiShuQRMixin(PermissionsMixin, View): + def dispatch(self, request, *args, **kwargs): + try: + return super().dispatch(request, *args, **kwargs) + except APIException as e: + msg = str(e.detail) + return self.get_failed_reponse( + '/', + _('FeiShu Error'), + msg + ) + + def verify_state(self): + state = self.request.GET.get('state') + session_state = self.request.session.get(FEISHU_STATE_SESSION_KEY) + if state != session_state: + return False + return True + + def get_verify_state_failed_response(self, redirect_uri): + msg = _("You've been hacked") + return self.get_failed_reponse(redirect_uri, msg, msg) + + def get_qr_url(self, redirect_uri): + state = random_string(16) + self.request.session[FEISHU_STATE_SESSION_KEY] = state + + params = { + 'app_id': settings.FEISHU_APP_ID, + 'state': state, + 'redirect_uri': redirect_uri, + } + url = URL.AUTHEN + '?' + urllib.parse.urlencode(params) + return url + + def get_success_reponse(self, redirect_url, title, msg): + ok_flash_msg_url = reverse('authentication:feishu-bind-success-flash-msg') + ok_flash_msg_url += '?' + urllib.parse.urlencode({ + 'redirect_url': redirect_url, + 'title': title, + 'msg': msg + }) + return HttpResponseRedirect(ok_flash_msg_url) + + def get_failed_reponse(self, redirect_url, title, msg): + failed_flash_msg_url = reverse('authentication:feishu-bind-failed-flash-msg') + failed_flash_msg_url += '?' + urllib.parse.urlencode({ + 'redirect_url': redirect_url, + 'title': title, + 'msg': msg + }) + return HttpResponseRedirect(failed_flash_msg_url) + + def get_already_bound_response(self, redirect_url): + msg = _('FeiShu is already bound') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + + +class FeiShuQRBindView(FeiShuQRMixin, View): + permission_classes = (IsAuthenticated,) + + def get(self, request: HttpRequest): + user = request.user + redirect_url = request.GET.get('redirect_url') + + if not is_auth_password_time_valid(request.session): + msg = _('Please verify your password first') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + + redirect_uri = reverse('authentication:feishu-qr-bind-callback', external=True) + redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url}) + + url = self.get_qr_url(redirect_uri) + return HttpResponseRedirect(url) + + +class FeiShuQRBindCallbackView(FeiShuQRMixin, View): + permission_classes = (IsAuthenticated,) + + def get(self, request: HttpRequest): + code = request.GET.get('code') + redirect_url = request.GET.get('redirect_url') + + if not self.verify_state(): + return self.get_verify_state_failed_response(redirect_url) + + user = request.user + + if user.feishu_id: + response = self.get_already_bound_response(redirect_url) + return response + + feishu = FeiShu( + app_id=settings.FEISHU_APP_ID, + app_secret=settings.FEISHU_APP_SECRET + ) + user_id = feishu.get_user_id_by_code(code) + + if not user_id: + msg = _('FeiShu query user failed') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + + try: + user.feishu_id = user_id + user.save() + except IntegrityError as e: + if e.args[0] == 1062: + msg = _('The FeiShu is already bound to another user') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + raise e + + msg = _('Binding FeiShu successfully') + response = self.get_success_reponse(redirect_url, msg, msg) + return response + + +class FeiShuEnableStartView(UserVerifyPasswordView): + + def get_success_url(self): + referer = self.request.META.get('HTTP_REFERER') + redirect_url = self.request.GET.get("redirect_url") + + success_url = reverse('authentication:feishu-qr-bind') + + success_url += '?' + urllib.parse.urlencode({ + 'redirect_url': redirect_url or referer + }) + + return success_url + + +class FeiShuQRLoginView(FeiShuQRMixin, View): + permission_classes = (AllowAny,) + + def get(self, request: HttpRequest): + redirect_url = request.GET.get('redirect_url') + + redirect_uri = reverse('authentication:feishu-qr-login-callback', external=True) + redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url}) + + url = self.get_qr_url(redirect_uri) + return HttpResponseRedirect(url) + + +class FeiShuQRLoginCallbackView(AuthMixin, FeiShuQRMixin, View): + permission_classes = (AllowAny,) + + def get(self, request: HttpRequest): + code = request.GET.get('code') + redirect_url = request.GET.get('redirect_url') + login_url = reverse('authentication:login') + + if not self.verify_state(): + return self.get_verify_state_failed_response(redirect_url) + + feishu = FeiShu( + app_id=settings.FEISHU_APP_ID, + app_secret=settings.FEISHU_APP_SECRET + ) + user_id = feishu.get_user_id_by_code(code) + if not user_id: + # 正常流程不会出这个错误,hack 行为 + msg = _('Failed to get user from FeiShu') + response = self.get_failed_reponse(login_url, title=msg, msg=msg) + return response + + user = get_object_or_none(User, feishu_id=user_id) + if user is None: + title = _('FeiShu is not bound') + msg = _('Please login with a password and then bind the WeCom') + response = self.get_failed_reponse(login_url, title=title, msg=msg) + return response + + try: + self.check_oauth2_auth(user, settings.AUTH_BACKEND_FEISHU) + except errors.AuthFailedError as e: + self.set_login_failed_mark() + msg = e.msg + response = self.get_failed_reponse(login_url, title=msg, msg=msg) + return response + + return self.redirect_to_guard_view() + + +@method_decorator(never_cache, name='dispatch') +class FlashFeiShuBindSucceedMsgView(TemplateView): + template_name = 'flash_message_standalone.html' + + def get(self, request, *args, **kwargs): + title = request.GET.get('title') + msg = request.GET.get('msg') + + context = { + 'title': title or _('Binding FeiShu successfully'), + 'messages': msg or _('Binding FeiShu successfully'), + 'interval': 5, + 'redirect_url': request.GET.get('redirect_url'), + 'auto_redirect': True, + } + return self.render_to_response(context) + + +@method_decorator(never_cache, name='dispatch') +class FlashFeiShuBindFailedMsgView(TemplateView): + template_name = 'flash_message_standalone.html' + + def get(self, request, *args, **kwargs): + title = request.GET.get('title') + msg = request.GET.get('msg') + + context = { + 'title': title or _('Binding FeiShu failed'), + 'messages': msg or _('Binding FeiShu failed'), + 'interval': 5, + 'redirect_url': request.GET.get('redirect_url'), + 'auto_redirect': True, + } + return self.render_to_response(context) diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 0eef00579..6a2481d20 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -154,6 +154,7 @@ class UserLoginView(mixins.AuthMixin, FormView): 'AUTH_CAS': settings.AUTH_CAS, 'AUTH_WECOM': settings.AUTH_WECOM, 'AUTH_DINGTALK': settings.AUTH_DINGTALK, + 'AUTH_FEISHU': settings.AUTH_FEISHU, 'rsa_public_key': rsa_public_key, 'forgot_password_url': forgot_password_url } diff --git a/apps/common/message/backends/dingtalk/__init__.py b/apps/common/message/backends/dingtalk/__init__.py index 0ca9d5dc5..e98bdee04 100644 --- a/apps/common/message/backends/dingtalk/__init__.py +++ b/apps/common/message/backends/dingtalk/__init__.py @@ -2,8 +2,7 @@ import time import hmac import base64 -from common.message.backends.utils import request -from common.message.backends.utils import digest +from common.message.backends.utils import digest, as_request from common.message.backends.mixin import BaseRequest @@ -34,7 +33,7 @@ class URL: class DingTalkRequests(BaseRequest): - invalid_token_errcode = ErrorCode.INVALID_TOKEN + invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,) def __init__(self, appid, appsecret, agentid, timeout=None): self._appid = appid @@ -55,21 +54,33 @@ class DingTalkRequests(BaseRequest): expires_in = data['expires_in'] return access_token, expires_in - @request + def add_token(self, kwargs: dict): + params = kwargs.get('params') + if params is None: + params = {} + kwargs['params'] = params + params['access_token'] = self.access_token + def get(self, url, params=None, with_token=False, with_sign=False, check_errcode_is_0=True, **kwargs): pass + get = as_request(get) - @request def post(self, url, json=None, params=None, with_token=False, with_sign=False, check_errcode_is_0=True, **kwargs): pass + post = as_request(post) + + def _add_sign(self, kwargs: dict): + params = kwargs.get('params') + if params is None: + params = {} + kwargs['params'] = params - def _add_sign(self, params: dict): timestamp = str(int(time.time() * 1000)) signature = sign(self._appsecret, timestamp) accessKey = self._appid @@ -78,23 +89,17 @@ class DingTalkRequests(BaseRequest): params['signature'] = signature params['accessKey'] = accessKey - def request(self, method, url, params=None, + def request(self, method, url, with_token=False, with_sign=False, check_errcode_is_0=True, **kwargs): - if not isinstance(params, dict): - params = {} - - if with_token: - params['access_token'] = self.access_token if with_sign: - self._add_sign(params) - - data = self.raw_request(method, url, params=params, **kwargs) - if check_errcode_is_0: - self.check_errcode_is_0(data) + self._add_sign(kwargs) + data = super().request( + method, url, with_token=with_token, + check_errcode_is_0=check_errcode_is_0, **kwargs) return data diff --git a/apps/common/message/backends/feishu/__init__.py b/apps/common/message/backends/feishu/__init__.py new file mode 100644 index 000000000..7f70fd35d --- /dev/null +++ b/apps/common/message/backends/feishu/__init__.py @@ -0,0 +1,114 @@ +import json + +from django.utils.translation import ugettext_lazy as _ +from rest_framework.exceptions import APIException + +from common.utils.common import get_logger +from common.message.backends.utils import digest +from common.message.backends.mixin import RequestMixin, BaseRequest + +logger = get_logger(__name__) + + +class URL: + AUTHEN = 'https://open.feishu.cn/open-apis/authen/v1/index' + + GET_TOKEN = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/' + + # https://open.feishu.cn/document/ukTMukTMukTM/uEDO4UjLxgDO14SM4gTN + GET_USER_INFO_BY_CODE = 'https://open.feishu.cn/open-apis/authen/v1/access_token' + + SEND_MESSAGE = 'https://open.feishu.cn/open-apis/im/v1/messages' + + +class ErrorCode: + INVALID_APP_ACCESS_TOKEN = 99991664 + INVALID_USER_ACCESS_TOKEN = 99991668 + INVALID_TENANT_ACCESS_TOKEN = 99991663 + + +class FeishuRequests(BaseRequest): + """ + 处理系统级错误,抛出 API 异常,直接生成 HTTP 响应,业务代码无需关心这些错误 + - 确保 status_code == 200 + - 确保 access_token 无效时重试 + """ + invalid_token_errcodes = ( + ErrorCode.INVALID_USER_ACCESS_TOKEN, ErrorCode.INVALID_TENANT_ACCESS_TOKEN, + ErrorCode.INVALID_APP_ACCESS_TOKEN + ) + code_key = 'code' + msg_key = 'msg' + + def __init__(self, app_id, app_secret, timeout=None): + self._app_id = app_id + self._app_secret = app_secret + + super().__init__(timeout=timeout) + + def get_access_token_cache_key(self): + return digest(self._app_id, self._app_secret) + + def request_access_token(self): + data = {'app_id': self._app_id, 'app_secret': self._app_secret} + response = self.raw_request('post', url=URL.GET_TOKEN, data=data) + self.check_errcode_is_0(response) + + access_token = response['tenant_access_token'] + expires_in = response['expire'] + return access_token, expires_in + + def add_token(self, kwargs: dict): + headers = kwargs.setdefault('headers', {}) + headers['Authorization'] = f'Bearer {self.access_token}' + + +class FeiShu(RequestMixin): + """ + 非业务数据导致的错误直接抛异常,说明是系统配置错误,业务代码不用理会 + """ + + def __init__(self, app_id, app_secret, timeout=None): + self._app_id = app_id + self._app_secret = app_secret + + self._requests = FeishuRequests( + app_id=app_id, + app_secret=app_secret, + timeout=timeout + ) + + def get_user_id_by_code(self, code): + # https://open.feishu.cn/document/ukTMukTMukTM/uEDO4UjLxgDO14SM4gTN + + body = { + 'grant_type': 'authorization_code', + 'code': code + } + + data = self._requests.post(URL.GET_USER_INFO_BY_CODE, json=body, check_errcode_is_0=False) + + self._requests.check_errcode_is_0(data) + return data['data']['user_id'] + + def send_text(self, user_ids, msg): + params = { + 'receive_id_type': 'user_id' + } + + body = { + 'msg_type': 'text', + 'content': json.dumps({'text': msg}) + } + + invalid_users = [] + for user_id in user_ids: + body['receive_id'] = user_id + + try: + self._requests.post(URL.SEND_MESSAGE, params=params, json=body) + except APIException as e: + # 只处理可预知的错误 + logger.exception(e) + invalid_users.append(user_id) + return invalid_users diff --git a/apps/common/message/backends/mixin.py b/apps/common/message/backends/mixin.py index 5652a1520..af151a536 100644 --- a/apps/common/message/backends/mixin.py +++ b/apps/common/message/backends/mixin.py @@ -6,7 +6,7 @@ from django.core.cache import cache from .utils import DictWrapper from common.utils.common import get_logger from common.utils import lazyproperty -from common.message.backends.utils import set_default +from common.message.backends.utils import set_default, as_request from . import exceptions as exce @@ -14,17 +14,37 @@ logger = get_logger(__name__) class RequestMixin: - def check_errcode_is_0(self, data: DictWrapper): - errcode = data['errcode'] + code_key: str + msg_key: str + + +class BaseRequest(RequestMixin): + """ + 定义了 `access_token` 的过期刷新框架 + """ + invalid_token_errcodes = () + code_key = 'errcode' + msg_key = 'err_msg' + + def __init__(self, timeout=None): + self._request_kwargs = { + 'timeout': timeout + } + self.init_access_token() + + @classmethod + def check_errcode_is_0(cls, data: DictWrapper): + errcode = data[cls.code_key] if errcode != 0: # 如果代码写的对,配置没问题,这里不该出错,系统性错误,直接抛异常 - errmsg = data['errmsg'] + errmsg = data[cls.msg_key] logger.error(f'Response 200 but errcode is not 0: ' f'errcode={errcode} ' f'errmsg={errmsg} ') raise exce.ErrCodeNot0(detail=data.raw_data) - def check_http_is_200(self, response): + @staticmethod + def check_http_is_200(response): if response.status_code != 200: # 正常情况下不会返回非 200 响应码 logger.error(f'Response error: ' @@ -33,25 +53,28 @@ class RequestMixin: f'\ncontent={response.content}') raise exce.HTTPNot200(detail=response.json()) - -class BaseRequest(RequestMixin): - invalid_token_errcode = -1 - - def __init__(self, timeout=None): - self._request_kwargs = { - 'timeout': timeout - } - self.init_access_token() - def request_access_token(self): + """ + 获取新的 `access_token` 的方法,子类需要实现 + """ raise NotImplementedError def get_access_token_cache_key(self): + """ + 获取 `access_token` 的缓存 key, 子类需要实现 + """ + raise NotImplementedError + + def add_token(self, kwargs: dict): + """ + 添加 token ,子类需要实现 + """ raise NotImplementedError def is_token_invalid(self, data): - errcode = data['errcode'] - if errcode == self.invalid_token_errcode: + code = data[self.code_key] + if code in self.invalid_token_errcodes: + logger.error(f'OAuth token invalid: {data}') return True return False @@ -69,26 +92,58 @@ class BaseRequest(RequestMixin): def refresh_access_token(self): access_token, expires_in = self.request_access_token() self.access_token = access_token - cache.set(self.access_token_cache_key, access_token, expires_in) + cache.set(self.access_token_cache_key, access_token, expires_in - 10) def raw_request(self, method, url, **kwargs): set_default(kwargs, self._request_kwargs) - raw_data = '' + try: + response = getattr(requests, method)(url, **kwargs) + self.check_http_is_200(response) + raw_data = response.json() + data = DictWrapper(raw_data) + + return data + except req_exce.ReadTimeout as e: + logger.exception(e) + raise exce.NetError + + def token_request(self, method, url, **kwargs): for i in range(3): # 循环为了防止 access_token 失效 - try: - response = getattr(requests, method)(url, **kwargs) - self.check_http_is_200(response) - raw_data = response.json() - data = DictWrapper(raw_data) + self.add_token(kwargs) + data = self.raw_request(method, url, **kwargs) - if self.is_token_invalid(data): - self.refresh_access_token() - continue + if self.is_token_invalid(data): + self.refresh_access_token() + continue - return data - except req_exce.ReadTimeout as e: - logger.exception(e) - raise exce.NetError - logger.error(f'Get access_token error, check config: url={url} data={raw_data}') - raise PermissionDenied(raw_data) + return data + logger.error(f'Get access_token error, check config: url={url} data={data.raw_data}') + raise PermissionDenied(data.raw_data) + + def get(self, url, params=None, with_token=True, + check_errcode_is_0=True, **kwargs): + # self.request ... + pass + get = as_request(get) + + def post(self, url, params=None, json=None, + with_token=True, check_errcode_is_0=True, + **kwargs): + # self.request ... + pass + post = as_request(post) + + def request(self, method, url, + with_token=True, + check_errcode_is_0=True, + **kwargs): + + if with_token: + data = self.token_request(method, url, **kwargs) + else: + data = self.raw_request(method, url, **kwargs) + + if check_errcode_is_0: + self.check_errcode_is_0(data) + return data diff --git a/apps/common/message/backends/utils.py b/apps/common/message/backends/utils.py index 5a2f90355..1a1a3fe8c 100644 --- a/apps/common/message/backends/utils.py +++ b/apps/common/message/backends/utils.py @@ -54,7 +54,7 @@ class DictWrapper: return str(self.raw_data) -def request(func): +def as_request(func): def inner(*args, **kwargs): signature = inspect.signature(func) bound_args = signature.bind(*args, **kwargs) diff --git a/apps/common/message/backends/wecom/__init__.py b/apps/common/message/backends/wecom/__init__.py index dd3e34c8a..661a8276c 100644 --- a/apps/common/message/backends/wecom/__init__.py +++ b/apps/common/message/backends/wecom/__init__.py @@ -2,13 +2,9 @@ from typing import Iterable, AnyStr from django.utils.translation import ugettext_lazy as _ from rest_framework.exceptions import APIException -from requests.exceptions import ReadTimeout -import requests -from django.core.cache import cache from common.utils.common import get_logger from common.message.backends.utils import digest, DictWrapper, update_values, set_default -from common.message.backends.utils import request from common.message.backends.mixin import RequestMixin, BaseRequest logger = get_logger(__name__) @@ -48,7 +44,7 @@ class WeComRequests(BaseRequest): - 确保 status_code == 200 - 确保 access_token 无效时重试 """ - invalid_token_errcode = ErrorCode.INVALID_TOKEN + invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,) def __init__(self, corpid, corpsecret, agentid, timeout=None): self._corpid = corpid @@ -68,35 +64,13 @@ class WeComRequests(BaseRequest): expires_in = data['expires_in'] return access_token, expires_in - @request - def get(self, url, params=None, with_token=True, - check_errcode_is_0=True, **kwargs): - # self.request ... - pass - - @request - def post(self, url, params=None, json=None, - with_token=True, check_errcode_is_0=True, - **kwargs): - # self.request ... - pass - - def request(self, method, url, - params=None, - with_token=True, - check_errcode_is_0=True, - **kwargs): - - if not isinstance(params, dict): + def add_token(self, kwargs: dict): + params = kwargs.get('params') + if params is None: params = {} + kwargs['params'] = params - if with_token: - params['access_token'] = self.access_token - - data = self.raw_request(method, url, params=params, **kwargs) - if check_errcode_is_0: - self.check_errcode_is_0(data) - return data + params['access_token'] = self.access_token class WeCom(RequestMixin): @@ -147,7 +121,7 @@ class WeCom(RequestMixin): if errcode in (ErrorCode.RECIPIENTS_INVALID, ErrorCode.RECIPIENTS_EMPTY): # 全部接收人无权限或不存在 return users - self.check_errcode_is_0(data) + self._requests.check_errcode_is_0(data) invaliduser = data['invaliduser'] if not invaliduser: @@ -173,7 +147,7 @@ class WeCom(RequestMixin): logger.warn(f'WeCom get_user_id_by_code invalid code: code={code}') return None, None - self.check_errcode_is_0(data) + self._requests.check_errcode_is_0(data) USER_ID = 'UserId' OPEN_ID = 'OpenId' diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index b24b1f460..44f7711b7 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -228,6 +228,10 @@ class Config(dict): 'DINGTALK_APPKEY': '', 'DINGTALK_APPSECRET': '', + 'AUTH_FEISHU': False, + 'FEISHU_APP_ID': '', + 'FEISHU_APP_SECRET': '', + 'OTP_VALID_WINDOW': 2, 'OTP_ISSUER_NAME': 'JumpServer', 'EMAIL_SUFFIX': 'jumpserver.org', diff --git a/apps/jumpserver/context_processor.py b/apps/jumpserver/context_processor.py index 79b1e450f..4245abd48 100644 --- a/apps/jumpserver/context_processor.py +++ b/apps/jumpserver/context_processor.py @@ -16,6 +16,7 @@ def jumpserver_processor(request): 'LOGIN_CAS_LOGO_URL': static('img/login_cas_logo.png'), 'LOGIN_WECOM_LOGO_URL': static('img/login_wecom_logo.png'), 'LOGIN_DINGTALK_LOGO_URL': static('img/login_dingtalk_logo.png'), + 'LOGIN_FEISHU_LOGO_URL': static('img/login_feishu_logo.png'), 'JMS_TITLE': _('JumpServer Open Source Bastion Host'), 'VERSION': settings.VERSION, 'COPYRIGHT': 'FIT2CLOUD 飞致云' + ' © 2014-2021', diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index 264566620..d8e96e673 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -117,6 +117,10 @@ DINGTALK_AGENTID = CONFIG.DINGTALK_AGENTID DINGTALK_APPKEY = CONFIG.DINGTALK_APPKEY DINGTALK_APPSECRET = CONFIG.DINGTALK_APPSECRET +# FeiShu auth +AUTH_FEISHU = CONFIG.AUTH_FEISHU +FEISHU_APP_ID = CONFIG.FEISHU_APP_ID +FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET # Other setting TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION @@ -134,12 +138,13 @@ AUTH_BACKEND_CAS = 'authentication.backends.cas.CASBackend' AUTH_BACKEND_SSO = 'authentication.backends.api.SSOAuthentication' AUTH_BACKEND_WECOM = 'authentication.backends.api.WeComAuthentication' AUTH_BACKEND_DINGTALK = 'authentication.backends.api.DingTalkAuthentication' +AUTH_BACKEND_FEISHU = 'authentication.backends.api.FeiShuAuthentication' AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthentication' AUTHENTICATION_BACKENDS = [ AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_WECOM, - AUTH_BACKEND_DINGTALK, AUTH_BACKEND_AUTH_TOKEN + AUTH_BACKEND_DINGTALK, AUTH_BACKEND_FEISHU, AUTH_BACKEND_AUTH_TOKEN, ] if AUTH_CAS: diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 64246b90076fa1817564f0871c2ff354386ec52c..7fca22dce84cbfaa97441fa9c37139a55b79d2ed 100644 GIT binary patch delta 24080 zcmbW<2XK|uyYKNgAqfOR4Fm`+p@m5AMWpvCAiWtn(nGJCB1I4oHbt7!n^FTJh$3B3 z5d;xYs&r{mL=@zHe|tUr$9w0@J#)@Gv-qs_top9E?7hM9>E9vaUWEA0X9$_=aWo9^ zyzKaPZqM5j>UpooV>AE94BI7+=1$M72}!TOWxP>auO(xui)!g z0!LyI+-RP|Gn6CxdER-viW=}hf6vQ+4=^o;4)DB`mv@j5rz9VKJ)RO4Puc&CgN&j-qz#Jf^{`sD6(u{tQ!7PL}9-nK3>3G*CR5 za4duBPzBRr9n=C^qZZWH9F7?%zm2(ZK5F1y7>{3J27G{Qk{3GA)epvGlqX;ToIH^I zk0Z00fEI8Dx%a%w*b;AGajY`P4UmZGDUU@BG#k}#p2e4*$I z)IBj4qi`l_;l9;m)Nmhaf*&yx-arlbkCoF5a|_CgsxN2dMySWE6K28gsQR%Ojq_0B zZ^0~h2vvW<#eLpmGP>p|hkITMOozIevZAhK9OlK6s55AVx*5Bp9=}A?J+KtDBkNHo zvIF%#_zLw@+(3itn%&X@=g4dP?D303tSCRLT*AUh5b<~6{QCrsuwb0?H3CCM} zChE*qV;W>d2rs$DnKj`g$h2rEx8XQ1X?FoyjvPv#>6x@oSWR{jSTz~`u~%1=8DTm;pw ztXT)O6Rj~T4npnN4AeqbqWW({Jx!mOhp;H+(>^lVqOh@UfSjnivm9!xYoj{0Lp>$E zQ435&UF+efn{YB}qB*EDUySOv1{L3ddR6a1#V@0F!1sWRCJY(pwmuToAkHj{I^)`? z9cYCbFadRmdZHfFQK$i@Sp7oOxXV%fw^)21W}$o(>F4u)C8IOAXAPcVU_s;EM3Jbi z%!X>05A`^eKe$)hO&8-+ic{gfj&Y^bV8tNu~fO=nqz3rZk9HjcQ+1W#;$Fkq z?LcSLiW5;c*=Q@jk6OqI)PNgM{db`T{t9)8PN2s73ALb`R(_5;(bN;!e@&E;OhL?p zg|IP(;3(99<52CUVmVxZ+T!m~TYDR|z~D)4;v5*bRAvd(IF(Tgse?L!c9YnDWx5g2 znfAweI1<(25NaXcVHlo5ZSl|MbyT~D*b1MacB1)Y`_#i~l*d|mKUSf95!Ekt3i}^Q z=9MY#n@cg&mbJk!d<%8$2cYiuNvOMd0qSmFkGh+8V{-ffbK*HnfzQm)ciiKb1~qDou3o{F5P z=e``K#m1;J?}))T40S>yFTl`N9r~KT? z$=`DmL|_E*EEt3NP&--+HGX3ZeE;u2MmI$-)YC8;)8YiwgnraO%TNp4h&q$~<{{J> z9zjiT88zN-sQ%BbKGiJu6y!B)&tm^yqrqDQbl0y%ZS_~EOYsX9$6s+hW}5A8x-U_e z=saq{*Q|Wo%8#%p@xQP!zT$V^s79bJ%}1z9u+Pu_YsJS1XiI)Xt@s*hiyvVIOh3o9 z%WD=k%b*rg8RM}ws^0+AJu(9|-U8H)u0g#icc3oW_dYUe_zP-9*HH_3h@~)iuB$JL zT4*&ai1jT#6!TCXkI8T~>hWBUx$%g38#R8~d2VO3p%&)LO-5T%40R^8Q7@#%s59?{ z8fXk^VarhW!e&(a&rz4)p!qdwp(juip2bMKhMMp%)Cs4UA1L$tpG*{iSk%PjP#xbu zP536}$9|}e^H2+0hT6hys0qKY@?liF6R38-pcZ@&)8jL%PxHRoas9KB(F*foW-N!g z`CdmYpe5?X)6MFqptgD`Y5^-z3)qOd2X^30yo5TD0SnytqfsX^3l;wqGwAt0Kqepl zXcf;P}h2dITm%ZO+tM}timj~-~0tLQhto;pK`IgBr%wca>>Q)zZx_q zpnISTsyxzs4|T??P-nOuwV*wy_6Jbyf5sAcA9X46EOGHdsB%@*PSr#8Z;5K(ZVCHe zh|Ej^deQ90?06S-2~vIF9#uo2@+L^wni3XwW_Hn3jW@0&9h??gl zYR5x;AG&K5fjYxDD;LK&$_-Ex4??|i-$p%llQA$bYM_M{UxsS84z-YvQBT26)J}ba zdI~O~#`QfUqlU?rx=*)ss2zw!ooOX3i`7vRjYKVAJZgZcsC#80>M2@{TG%es)*nQ* zKZYuwHZLIa`n(%tqN#X{T4B0n?%L)kQA1V2Yz;(e%ve}`JgkLc4%E|SqTyoamMM-6NGy?b}*? zS1b2LU9w@Q9hrc7-2A9<4`Bg3vx@!KwF_G9UJ$XUg_Ol2SRHjq5>XS4!!Vp?<=LqA zi!lb*V&KwXDCH9vhCf>TJZb^gEdJ+e_FrfAf`BGUvBnLU8FdDEP!ktHbu5Rv3F~1P zb~L-8`uD*Y9EG|>OHd2>8jIrzRJ&wr-A-okktswV9<`7bs7uljb6_9TMAOXKs0A%R zEpP?unr}o+v=>A0FzV*~2G#!@>K?d)TEOq9aeUr7cg87E*D4BCksniFF)Nox4N%?6 zucH>)#>zb~Ipsu)k3jXGf_f}xp~hc@TEKQ!_IU@$XoaWDpHXLa*){O)pmyRp*1&A* z-P7?VhEpDeTF4~SPRv0qWDTaoO{fWXp`NZIs7rALGwJz{`pB)U5UOEC)XM9l&iD<~ zfC(1wiQ4K!%#XuRTmB(xK`T-HHloJ)3{&9&)MI)ab;6+^6W8+}K}IXff_lzNqOM&7 zjK}s^1}9@m`~o$=5!8Z@qZV)hwF6hI{)yH9W98Hv+=*mF&66E{IY(adqXz7Q+JW)rbkxG%#|T`4+Vb_N1twYfbE`ju>VFb-3D4{ArR-ZmW*C{Duq!s*?k4&ai&6d+b(d%S#GUD@n3r;E)R~S!wcCbT@KtPx>36t~ z;f`2_@=$Do8<9=s>u;dm=WQaRPqo9C2QOh1hVFD*ogH;EmPK8n%9sutVkCCJjF@Qg z$(Ww<`rI3pZeP%tE;sX2!axok&2fco^#D znTUFf=A!QYWmdl$Gf_@L-E>D#vm>a9enKta zs(A;s(8rj7FHmQiu-EOtP*nR-=G&+V-bL-id{q0@m>qYccJ`;eK6g{xC!o9kA1sJn z_PK?;hZJof}+L2FDXL1yC;AJd{-hMZ6Da=i=GHSw3s1x&H2Aqbvc|TA<6KqAD z;a=1NzP9oc)IAY&z+J;=)Bpuhm#h+MA+^npSeo)s)TP*fdeeP_I)U#|Pr(JOjJ`W$ zG*ID#u3;I}C8>zIB-K${+YEIEZBYyDj=gXI=EZZUg#{mS<77g$&xTr19O^yM9<}u| zkoG=r6&anurx=3AQ7iqyyo{A7-@`C0a@hToO=&DaxgW;ja@>LkFcgP;<=!Ww%%xaV z^{9nD!i;+UlOJ)<;EAv2XsJZ{Ilcma!G$T9aOD~W|DCtw`Tz+CtV z>Lxpl>h~D)VDxt`UI}$#?NJLEi|W4|^W)L)*nd_0PC#24dEDI#1yBQ5LH!cyh)GpfmXBfvDEu?eKIJ>V@au^3f^&T0QJ! z6U%45ih8P=nElbG4l}L6a@5WEsg;kK7tKFV3yHYt1}x;1X7Dgc`7mImmq5>gSm2%`Z`J#?z?zZlMu7;Yht(AwG@1X`*hvB#xwUAv_K8mrFPg(pA)ES4}c3-(7QRRwe zU7uxIpeF2$n&4%8lGXcB*KR3l;ytK|k6<$V2_x{Fc?&h(6V!N-cU*n68E5)RkZ{H5&Q`CJ~)dS=r`0v56l;+g+<(T^#xJ&B`^(EHXEV(w>P^W?R;J@GP((e zp|*aSHCTx{vyV_`yb1L^VJB+B6R3XoQJ)>j?zwUnRJkB(yeg;@Y;EO1sD(_&z`y^^ zA)^VFp>Dp9P!sRPGQXc|+o5)>r#TAU^G`+{m!mpmk zL#PjxpHN$x?twENweV`F1vRyD57aorOdo3eNvH+AXYnNuxc&;PB#;ufSjBG4PkBG8 z{+9U!HDSm@cP1H8^>Jn?v#Qw$wUD+}?qv2uEojt3_FoMqS;cg7Au7HGb;cV}9S&Rl z2`its@(on`$5wu3hCkw~Iq@h|eFw~dT~Xr=^O4aRjYplyY%70g4K`tW;yY1yapuQv zpc1G7%A@*MNA+)pn&3^;gac9SMxdUWx2?Pa)z7zqjLz_L%!H>=9dDxsd}w-qxN>SU z8Z}XzS;68BQRB6-@<4N}Io(`{%;WRcxQw?E^*C)ut!zJP0Vl2gqLr_sUa^nNs3&g0 z<;=>cezh;4%zjes32Ub=Yej3smrrN~ncgw)g|{g&Fau`@N7A)xQ~P z=iW5CTfDD1(wu_TXg617J^yFOXoXj?82)MH_@}O15w$aYP!o(q?Z9}{0#{mm8)}@- z%wy&y%tHJTYN4tBa^pm!PXok~QAKg9sD!yF*R*(7)J>F#d2tjL!eyuhAHy>E0`p^; zzulM1_Na*mq3*5es0FStKlz*eSKtVNZ1@XmqCZg!3V!BHYi397K)hKD)xNBitD?rM zgBq``ITW>HepLJIsPXqaWB+xNd_zDTADPLYyOl?y8kR(LtYX$fE#wWet=S3Hz9(v@ zhFE!#xfZqK+p#H*`R=5JyFbOrlF4Qafu$8|@wYy;6L$!Zlh6TBP zsZk4xM(topRJ+%lKCcEDO;F!#g}EqqMRk~H@tLRz7n>iW2HK73a6f9CQ>cEIQ0;%i zVEo(s2X!+B1qZQ9K3=J0vJ=RG`uwhdsjw~<#pdQjY)W}M>Rw5a%$W_fkRn(BOXG6v zfPslZTzzIUr4fGhbkY{Fim@7x27M#b*bx;d$j(Y5R zSbQq#UYn2lhP5>;$QSrM{|SK@0>?25e@9({6v^EL8BqhqVpc4LI>Sb&EpBD?eNi{t zICG)JlThssna5ElbT&DE|DrR$L_lYD3pLRb)J~*K;o{L|9ICz)s=l_xTbf-^_rL%v zFGRIpgId5Y)O-i5{#&1A&Y~J#xAI?BP9AOxFyl~ndnvP~#ap2IcSc>?A*iiihFa); z)Z=>+wL|AouWa8FGBe2(N$Ixo3)HLg6zbi6A2mQMf1jdwaV&`qtvn8O36@wn$@~u0 z?>E#qf1}zJ;_qWLPBmo5eO?DLy2*N(W6inddUKC?3U%#nn)gwc?g?s(LsGk)NP~*U zqS_a+cxkgvK%T$07U+vw@i=pq#h0O;l5ME1J89mw`p`5ko&_~wakCO?;`*pd(iC-x zMx#D+-owCu|Mw}GNCNv5z|-bM^A@VZBQty2puj&c)IdFUiKu?_Q43v$n&?Z^0#2e% z>?~@5w=Dhy1ONTsKUR?{ookQ}d7<%u(h$sGDjYYMhTzKVp-te8Iei8vl;@ zmzg{R&%Zim%;2^#8wM6+7D9C_Y2_-Y1=TiNq88lU>PMh1#W>W9Y?{TFqsCc>xc0-v?;z@g&ZF92H6NJ1 z7i82SBBN_i0M((il`EQcQCr=@%Dqt&4maOHEo=d*-Djwq`UL9cy^7kI)S2A-ATzQc zpI3*B25OF)xT~x1dRuuos^eI5syWB}0M&mj>I^?cjdKw7ru+uAbLUXw+()(h3sdOx zKY5g^NQbJ3MqQ&gE7!t8l$&909E+N81?rWWghlWRRKGi@i6Sz)g+!S-Q1QH|r>2PF z`uwj$Mgulh06Uu9Pz&l~##wxt`M$Zz+={vcdr)Wo1FGK*)c6lj<32^-t7O8m zxO<=)szC?T^WPQKa4~A2b*MMlZdAMLR(}^Y;olZdpVif8K~0O zrFBsQHnZ}Z=3A({c@XN%rWjeu zs~CZra5Cy0zX&z(msbCidCh!idfDB0sZkST#88Yy^^ddqDyFYC8J&3})K0WQoly@g zi~~^%TxRuaQ3HKyo$_Frn_FtoJrsJrE|B5=}hZgs8x`jrf;)PHPEst7I^+25GuO1l<(9-OVYB)5| zfXB_^Gf)$LVC5~S-wk^$e%}1u49Vrj$!x}=CN79tPzkB$zp@1yn61rj=0H@(v8V|r zqZYW*;u|f#9d&8GxAJeO-vudR+=MZxuiu4HZ^k<4)3?>uWOCtn)J`l%t#GTk2i5Ky z)En@zm9xaUYhKmtjGt3K5%tcmo;xV;pWpYwT9iM-@)(@QJ-!w5@cgS`Qvwxm1eU|? zs1K#PsGBNfUUxGV!j_cV;CS4MrLc59x4^eh?*%{VMAlh(Cu*MWFb1EX#?6x7=RS=p zUl=z`cXTv5cRcv1M0WpKJyf6;nz^@9$Go9uVyp)bpQ_j0Vn$+OmSE6;`rxBQwDqgj1=XjCxu!6nFjWqx!Y7au3vagHfL) zQ>?rg6DV)Oz<>XfzJz;hvYEwEXIvXKU=s|Sxt04`c?{~FnP%mksHfqOl`o+B-$sr1 z47H%JlCE9Wl05$v36vn9zm4jLnrOPY&|HJs%5A8v{S)<>@d7n)vQn;JYE*r+8EY26 z-o%Td7PuI-knN>-{x#uO7We^mMmNl;(yl`dvpwo&9fDfGT+{*%#x@f8g7P`|B$ zyQ|NjcH#kQC;mW9?7ixYM8)%?o~~l3@tYwh=JVdP2K`ZIJ_5CM^H2jX!Q!|KwUsBa zD27yYJ5UN$-w{K|Y}$~j(hJ6avJb3HKd|Nrn?#pkGom#v(ll51EPJDKJ%YRAywFnyNxIv*d z<_?tj!bqc~#B|KC4ijC`J7?u-{#i9M`(kYbRqB7_qN9r=@Mo%*r$VWhB8-|uI}Kg| zKO%iVVX^A$xJB$RNyjo~TaDXkvz7cF(ks+mCsvm?m{-dt$Vf$h(t0YIXmpMxlw--? zApe|BYw!+mMN*;~1MHoDQEcz&=tci!~uOkF<^QS>kQ*P+$oDea5bk6$9vHQj+#%NquOq z<0xjMtYaYcwMkuq`HXB%U^xXHS!poLWdnce3!q+Y4M{Dj3uTZX+UCHKSc+I*o1iak zQcmQF9I+l&jTYd~zS z4N#Z(Zu0tyv>6A|CIhLT+FF~xX~W;LdSB9JH*p>HNTG~>3w;%7@C6O@Eg~&Jy-%AH zdwGNr`=zy{|96*#6)en##nZJ&{k!3ucHReJ}?U)K}X zO`ZQWq3Tyqsbd=P`_!kvA25>mAo71vmyZ0^Kr5a+V!KFPY1522--x}dqzmMa;7pQ^ zUumOb5?-V(u>ZR)xP@YQ%d2XyVt~_hs7x#c<*AfECG{q4Bwm{o$4=G49mG=8 zt`8{}<)28&iRl=Mi%Ap6=cWCI8i6n`AsT&a6E>hiM=i>F>FYQ}y^aLZRBN|_d{6QR z{g>)xj!ka8)M_))S=J+udEcM4elFklR^mrW;FwR#6P9mF-87=BDHkN)!sb!KK7rQ! zGmOQ`TKOe8n0-9`251Z$ZwHkz#H&;Og?@Yj`vU(Pu4J}|%h;5PoHW>k@7UlP^efT_ z#JdyY`-fMJwyJ)I`~>S?kHJ%rb`dX)>+m^s2WYbck6F7xl-<#cN06L8GDlHaoOFXq zeP$PC^v;y?l0QbQJDw-4BJLyo_@6=eLPEPYsr!NSz5ksCd14<^`?l6+$0TcAf@S?q z?12A7gGm3y1|dE_HP`8%hbUjrydTKNk)KNX-g-S|q)e74>}6(@3YgtE%& zjFXLaEooO7OOkYaY2z%S?W1J>dEqb_mB(y=qk*O@RG>F97O%lmlfERawp;qNWbWZWGull*5P;DOM{~LHiM>P)lY5kEvCN5oS{!L z>!YzR>kp1{>PNkf5%{h3DNejNV_on!X_PtqSE5BI#rg*{%IrJhntMOdrYmWj^;dWd z<&?BcO-fHZ7d?;QH&~rm74ki3mx*@U$j>1^6xUJKv4wmL*2FlHj_zg^@*9HrikyXk zzNAA0en;>)4d#-bl0um%(gqt%osM6Ky*ys=*KC~2w}yz1)Q|MK)qYN|_sB=n^ec>F zbRFL@>KD{q!9tXOB;6*zjFd>bhib%8niNm$6PtZM?xB2-RE4p|;}bWR`_!69;AfJK zSsI8`npB6zPw8Bjcpa-(o9W~W+M){MpKdU3E%7VV*TwfpE9qMm>ss5?)GeXBiFB9z zaD8fbpwS-;u%Glk6+1~Ih~=Ww9UC|e9ww!vZXO*MQ{SJYW4*OWMO_A)XaV_C)Mck` zEbTrer6*-4uA?WZ8f}|s>&FsoK2JL{kncx_qZooZHj*z!{xzGVxwWrIU47!e zQQnBBN%O6)CN^d49;8mBdz8lqM&sG0?0>UK3g0M#MJX&IWg)+V0czlI$}f+q#4Zv` zKpp%Ij@Oek#M;y+R+93oEUuNcc^5}p{VT+m2YT|;fOf^nm!)n+Fz?a&WTI*OnKhV# z?PxTY*a;h;Ao-J|$^J}DbNP~p7Nv!b4&+Z-tMs-vA5rHgmIBAnrW!qU@Vn4kN4y*9 zZ_2+~znawNB%d5L#5?3awm#~YoK(@qE~bBX*vUXMY4C<>IkM0pJ7&co;;l*Fk-}-$ ziL`(;)_=2Uo|;u@b%v6T5L(A$f|ZxiBO9>?#Qq|dntUOvdkd=(Z$T>OZ_+H%_d2blgoCORD(Eb4RGw`Gjgl;0%JU$}S?Hdo;M7gF&S9mkP$T%&S5v1mH$XiMrrc^w^fWOR53 zUJgRLL}JBoKV~An#_DoW{)@hR)$l$fc8GMz#@R_cVE}(rr?Lr2$0~<+o_I;i4<;W* zdPI3E>NtoE8SH{9dP^}A3ph(W856|gXE@rww|VB!1C&nr&owXU>rL(i=??AZTHl70 zUmmYB=5o?V0+UF&sH@MoFUW5r-JwoL9%7Rj|2AnAX^M>zPJBD%!L%D@v1WL~<$c}< zuD|<};CvgfHIY=L&UF69I$p%t#A=cHTf58Dw;+Fs^da#*v`NOKTdYq$^1-yx@ds_% z6E8)q68U^yM_19-8mlyt3LS;87xDI1R%|KdZz$ixA4uCtFOPSbWD2Ra#g=1Hf1j4w z$}b>ynwr~IQ?}z~Mx02yGt_f51@_z4@J5%rZxYf1Y^C4#AbM&@VI zHX0YkMl@bR{$ui0NE1mZiQPgSGZ{EH>37P%;BO=y7fHP={|@;N7^4v>FG)v?)xAZz zA$?0@4lVCTDmv4k9qC7FtPVPgSf>W$$I&T~@?(psegpYW{#E{r{_oT75$WZT)%N}{ z?H*9S*5=qlgEIamts`@kA$R8gnK=cm_Y%+NAJ@95?}g2g+gjyj%xLB~O^a*9a*{Ta z?~8M6?kMu-NacvPvHpFjTR^#^wIS;rC;uJkAgQ+k9FIv`N!96BGMVQu+9upLkg;^6 z!E3aAlcZy|#V_MvVk=2$h`&a;6NWLyR8lMQE3p!0us#`Vj5(C&k^htWugEvE_);85 zdk&xX!h*q^d^#GnwDQMv+E2Pos}X^=yev#{+$PIu7veQyhes9&F${s{vI4(*rNB`ICsdSOXD z2MtM)y;j$r&3X)u>6Xy5ch@dnwVr*u^z7R`#zm8kjLI9F6yeJr?(aS&cbEl7O<5jn zd4K4%_zZ4W1>*#o|ZH=A-d$o_zpPK&v(?8#TJwT1N4ZAYu@b)pz|J5i-@ki%IrM)qE z{nd>Nuk3v1>ZVE8myS!?dU0K7Qm<=Al1JZM@xhgytFNxya%0;n@A_x6XcpsQN%`+` zp}O2}mBJtKG$zb~xt=Z!w!DAc-<2a>_~x7iH#TkZT*064N1o8Yu=8h@3d)+=MXhrF z-cmu4NjLs3k#W=6A{*6t!b@=Ae-9@Ee;}xwboI#RN4>KEHo_P^M1`1l0`L z(X>#|qJLkHE1&PVK4a`M delta 23892 zcmajn1$dX``}gsCW3Y{l99`R}(GAklAxL+KC!9Z^gM3#~2hH|4>I8XS7VMYZ z^Y$h7yyMlB^}O#|dtOYS=N-Xw0iO4$t>^8=Z`*lZOX^3p_q-|iGY-bCA9!9f+9%*d z;tH{zHyppipRjgE&-{PF!8ct!FD=gM=6OkQH73K2 z7>4^W9UjAIylw_{_q<5r+?Ww-AlKx*k3}&S3*uZ@3QWM8x3j7w6;&}|mYv%9fOAIC-(%aRi#4zUfvQmh^yr=;x zU>dB2>d+Q-<*}%Ndz+u2`b|RZ)FModD^UHmSbh&CBR+`f@eFF5d*}}Gd#ZyriwAkE;dUg(AF1*-} z{nu8!CJ}+*{auGB)IBYVdYCF(zBQ&H?uNS35ttcgp&qIYR(}RH(QVWM{y~kO;$zn@ z2dci5kAfz6*D5-pu5>V}!(`MwTZ|fLJF5K=)WUCIT6~CFaFPM;R;EKOG#l!*E{@u< z2AB@pp%(7@n1UM4L`|>~HQ;vCfF~@zf?CiMi$eywI2-CUD~1{HEmZ#omQK$)HP+M0Fwb0tA2^(3y9qP(H z#vC{qOX3DBjtN*6GYxS&(-edB{`aDw1@uQPz=uU}25JEZPzyMMx`K14iLRsi{e`*( zfkRz=Fs3I?iYMfeTebnpT41HNCyhlM>JQUS&2`aw>b?+{q2D)!?@Gv)F7SsZw zQ4{1x^)H5cP2WLXP(AZ~vyVA?82hh>Y7Pm#PODK@wgYJi_m6Ffz2eb8{%F(c|N$%|TWVbrrw z3iS|HLrv5W^{li+P1pgI?}z$i4@Kn{q5oN1PeBvzN8O53s0KI9C#bCo8sT;%Eo#7Q zs9TmB^_rGJ4Ordko1?~Uhw9(M@;=N!JPzsS^A=Ok6|AuaJJ7!%i%+4p@<&v=>!{c1 z0ct05jC5bJe5iIcP&?Vc?1H)_qft9EA3wn5m{IS4$S8MZ+0p+LqjsPeYKyC&Zbbvs z1hHmMj3OR{dRFG6c48T7C)cCC7vG>>&r7Ha_yyJezGCM0o>I_6fur5TDN#ERg<5f8 z)I(O*;-;vDypI~N3#$JB)WBmP1+>6=pTMWTl=I@xD_!(-ROuh;3 zHOhyXNmRhx*b*agI2Ogls9SUfwT1tnUcb~6-9mDq1}ufTRW;0}sCFGt3mag0ANt?_ zsaCNVb%kG|R=nQw+b#b!W+i_Zqwto+NhZ1WIZ^k#pv7fT<5fjX{Eo$qtiC0N>HYtJ zLNpC}paz(Zx{`&cD_x7ahx@S^22JKk#@4tC1E;w5dr`OM1Ztccm=qtP7V;9ckie;K zhmvB1-v5jgbT12{I#k4DSR2)$IqGR|hkBm}p%yqDbN359cr92sD)j{ z3?9Z|Pz9BP8;R=)^!kJq3c z^1YY>52EHfhdvE-oq|^Q0Cgon)1ASnD@=--AO~u|0;mBhT76yAQypuL#|p$>p&sJ9 zs9PC2!`+IkSd=*D4EBFDg=Qr5a0SeCTONs8ag4A2kEY~h8YC(BW3n_smuqtYtfj$bFcogQx$yTu)a}a-niSRb+gYqkCtCP-l7DP?Z z0QHo&K`rb<)QBy*hMsD-9RO_&)Y zFa|YYIn))^wzvtVBmMw2@gP*c*{J!JU~awt8z`vb1=Px}qqguVYQonRhs<^DQlr{s zMJ>1proswVU*BwnT3{@u$3dtant@utrx>mGec}T2%XN7Uwn#qb4qm+JWk*Tlqff*7ir{^Ldjg zXuvpBgU?a-I3BgdyD<`vq84xu)8jv8x+U)QD}=hjre;S}y8#%5(@?i`18Rr%`So!R zDP$&b12f`l)D>l1>IRH9i=lR;D(1w-sApoJ`3Y*_qfrZ=g1Yk0P`7BS#Ya%{-Nc;C z&x`0@r(CE7RK$YV0(Gy(VhJ@v4fG>MGtr9>RA} zJJ1Au>d=vbI`lx@n*pear=mK1W%XMu-ix|r-=TKoEb4W;ff_j7a`&g{Jg8gO5cPfN zjatY!EQE2(x&OK+2S{k5)2N5=CyTG6I^4%7{0sfJW`*1OXjJ)iC91_xd!&P~zUGg$zOM z#01nr7GP5R0yW`!)a$qxbt^7nTK)VF`^v2>8r85UYUPzsS6mx4U^C0VhuZ2+m>avJ zwtN<9LGw`kmZ8SkfMK`;^%@^SUGNL#_5KH}aVtxP`th0vb?+);UTlP=a406lc+>!U zQ42nVTEIEf4qUYQ`&R#l#er+xg@mH!NrnFRKOY4RPz-gC-a=h*O-zRMQ3JO{ZDB9e zl}i-?;7M@+l{`t?He!OPBy~*=FB3_7*7##0@jK^Rb;&xaCccY%2*H{=!ZRS^X?1%%=x0FIGh0I&r zL?f^W@fy_A{WIzcgSNV#dfBlgaZOaaVVED+;k$SV^w@?qEx5M3<5KKv!4kIuI^-Pqrd;`=o)E={8U(3%#-P&cS zXJjMlA>50>djF47&_i?v_0U{E-Lq>r0w3a2?6Z@8P#!6gBZ`%!pf2 z@Bb(BQe9*?%67hQN$mj7Bu>6_FwPg0umZ%CF%;-q5mGC7I4%&gIee% zY>zilS6XAg+krNy_U+A&P~-PT?Zi-2`$?Dum+besE#6B)56KUxx8Vln$Jz(nLi(cy z7=gMK(@{IJ5OpQ%F)JR%;&=-+amH`lcOWNf!kVZH>wx<1^zu>Ay&r{|U@qzkSD+TK z!Q#uPXCnc14_~7ONO{oRvK**|6fmn{N#ZuBTQL(8<3`j4Y(rhJ?;wS@DV)I&Ont~T z%#6Av(WqOJ2eq}OQCCn2wa~iQ6`NsBJb+r*ZA^?WQ0={M-GahV-;FBB!|n6>Qc#D9 zs4G~ALAV9A(w*jEtVnzglVHSQ-VDry#jpuR<2c-it56GQ{hj;1v^U3~zJ#kVwch`W z6!amvkGU}5h#NQtwdL)xEDlCJ-CIx((J55F1T2IvF&Oh5b@^hb3#f^@peC3lfWNlG z?8F}*V|?cK=26f}H(@AlM@@79b+3QMr1(2(fzMI5B=Wd>b_%1OovNrEXoQ-uA8On| zm>+$Z55K~^cpiPaXWsYj3UXmV!X{V%M`8@F$4I<@dYGP}`lUPJwzeE9-wt(UpP&}9 z1l9i=%#FWVKJ`hrLnThK|GMY3Noaufm>GwoUY7-^56uqLGjbNSBMDfMt$c9~&p>Rn=+h?8ID z2`1i&n~AqvaSQ44i`(L17)E|1hTv4xcVRB-8ChlS!${&EQ1wqy?Sp++-91T(dWy@T zJ|sO*TQ(V^FdlX9&Y|jGV=2sfjo)Ul3C1hF?ry#dYf2-HW*Ia#TT22`9Mb%o{3hN!oylQ|Z(L(8pxJNooAp0LDK^G`GUj$24} z)W8+ZCT16_A8zpi)GgeETF^1n75`-3!6d{_t^U;=?!N{Ozw0_=N5#cZJ5bBw4pu+d zoM_Is`qk#w=6Q^u|9#Yi-aWUlU^5D}GiC0v|5|Yi5}L5LHJDIqpuHz>=Zu7LbxCGVR(odDBw3YP>7ipwXp1{`pTBC zg?c!fnjNgZuQ?dQsULxQ2&W=D@AH;egPo`=+mE{9L#Uq(Cs7mLMs-Z`yZhmh5q0Yd zp{}T=#UG#+HUu@{NYr@KP|wyp)I94kxqdpt`wRTIHBXyYF&PaXn15S7?139FlbO>j zWR^CopcYaWQ($BBL$j~c`#;JOaaf6X8EU0BQ3Jfdd>HW1O!0oM7=o)I@(<9Q219Cl%HtpAA*t5mRG#)Of>D7c>zy z?|h#nR$7B?*p`a@sHZyei5sXaYJke90qURzXpNenGit&isCFY!Z_Q+jze4rfg1W#% zs9Wf}LO~s$pay(thCFp~S~ELpqQYhs%Qr<0_<_Yk%<<-Ib17<`_2yRO?eckhC}?HJ zPz(6U8r-q?0qPU`%FOns8>o_56V9~ChWhaI zM@=vawF47T3tWRaaX0F_a1GTi{J9$^!pw~s$d^G~aU)c_cBpYWqW|Cje{2<_Fp`Ri zs1B=957Rc(g!{1oCZHA?@xpy4s$p*8A(#%Aq9)#fY4Hr|itm^&&Ezk+|N1c){nAZT z0X1<=vx(UrwelWjf7HT;T09mt!4%Ali%~na8`bU#YTP>(KQ&*!WdAjA=qqPRGd-$9 zHq@2px441X7PW<4unZ2yhPW9QV3NOi(s3DTCmR3b?1mb53~Jt)K1X^L~ZFEtAA`hN44X}l;30+Nt6lIzO3b|q2_Do^zrvN0saYlV@fIpq6V6P>Np40 zVKD~c1~VS@aBaoJcpS6f8BC53Fbw~}!k9F`Sr!`+cR~N3|M&O{{7#3u@*Ak%-R|L1 zj0m)eQ1!>nv*so98dj$M4%WeZi30r3&c~=*_!;WL)}hAPrPxQ|fK{AC-Mh;cKSAw4 zK#=PYW~MeXqTc@~%!YL^1iPSitS_d*QJ4m2qb6RBTF6H9zyEtJaSZje|7h{wsFf#8 z9N_77%8KgvHtJ!ki~3cn6K2G&7=>d{-+>jVTd)V!?+9wV^QiWB69>5e{x4Ayx5dd& z4RW9!wo+z&%XdcY$PjZ3>WZeJu6zz^qD822)}VG`pXE=Qm#zM85}#}E$|}Nx-2fR; z&p>XA>!Sv2gIYjO)P#eqexx}K)oy{s>n+}G@k#SC>S4d@v%(9j2n%roWI)~9{HU#O zhPsl0sP}g~YKP*m6t2N(cmwrT^bHO0e+eg`KGn-n{m)xI0gDs+0+YIirBEFkS=`wi zjq11>GS4L&^`Rh+=Lov2WqRoMQ!OR z%iln?e`xtWs*NA0qjMNkW`YBsj~hp4yZW6Z$MA8)q5zKBhjs09zk2pnzkr{+>~4XXWCsrUa1g@TwQxqJOepayD*TIq+ViAJKX zd^YOJ7N8cm#_~HX{@VQByo7p2ZewoDmcorw9eoDH zkE0fJ(Y$Kjw))>urH&CzbAEbr0eHBQnr zZk+U}iDS?|zQyHG{i-;9UR_HxGv7xoqzkI!VAMe4P@l}HsI6Os8fX)$-EPz;`H1Du zSpG8V7TvWtByE8Im)NwJUGIMt3Yst$^=a*og>X2k<9gIYCr}Id(fq~o38=T`q2-gN zbK|8#wU0LQpcY!#;_?`(_rI!Dylb{HJDL4Zw_pV7%IBgMv>G+RX4JsDupEAedRBtc zy9>x_mPb7stx?~b0qFnp|8ffcA0nuMZlET5i2Bkc%HSp{gsLxzT2K|sw=mnG7S<8f zt`};YVHQtEEqI~jzsO+k|2AuI&?-)&Zplsa32MTijBemesQMVxpOoH0{i0FbY-qMM zKSKYlMfDqwTIlqQy#HG1d`qk}H=>^QU8ohGG|!_3`We;l4r;5PVrNXA$;AWEKY=+P z)&2|AcpFg<>vo@2>_<&_0`)7{b=1JYnO(;yv!Geltce=1iN&o^zYTXr_3vTzGDGyfwIC-b1}kk1P($8sG&Hr$#+Y=}{}shnldg#F8%+h8Z)Hv^%olz6_K`nHUIo6Dm zdjA(&Vy(Fo)$uTD!V{>0f3^Hm%fCWhamGm3t{Cdi5A{(Kc0&D{-VgQVoQ8RDF-GDM z^l2;ZQc#EIW|An^FaooaFK=->)IA@MdvG=CXF=!a0RMlEKOJijU&S&Qlij_x?NRLp zU|C#*rSVdB-hX|OB67Ha3!@&whS(f^I1(>l32c?qEo>U`DA99>Ugn0{s7SDmt%Q za4*bCehQAoudo&t%ja&*5Y$flwos@`;Rd$ABKh49kqM}Y523E;IO_NMUr>Ljd}1ak z;4UBos$DLNOPkeE<216kC2H$CB0pVy-XsdTXRA@q!Wq<+{*HQD|FSr`p!;Oz$HC-F zpax!#db+ow`W;67s&?AqJE(S#QP0YA)GZAyBzgaGQ_xdh6?Kn0q6YAx2AqSsMT^a~ z=1$a?^RRgaHPO#l6>p;Mb)Ld5Uj;Q@ZL=}@-~YB0v~^ujD;#F=6mzi|j}xi?7E5Ep zB5q+5QT^sxyb?9uX4KD=!xsO9?TLR!Eu=wF-haJDttiM|sEVW&7 zM@7`YRZ#uvqUu|k@0*>l8~GlniGM;Zl|tUKcWV@j`};F2dIUVE9pKsRWKKEU(}a#9xlUOs2|hqe5KqsIu`Zx4@bQo3s76Q zAGN^KsJG!J=EG!fxqN9XPFx4o-)H&HP`7j)s^3A>!hS<7_=)KYDD5heW4ZwTz7})P zpgr-S;1Krb?=&@o`C@t&#`zrYO61xehK{z*+}nDAYV&uogFvuODg7*iwSbc zuYA^EH}PO9_2JQv)~TG@6&=4iyg?e3<4*=oMfn1jpno7|AFJDsS>Ke&2h-*neeP1O zfzcX(Bbqjg$?5I$CRm3t3OIhWcw*ef>gj!17~;*5!pa@qBryEVwNUP=Jj1T19eAa1 zCFkb^3sh&vHFDo@>R7;RD{w1q)>7WdnTxvHpzz;k-yWDR~`-;{s|%Wc`WKGtLBB)}>~=4XuYUcU;k$5x!5z z*0*s;>Mxr7|Eb#)%jL!%)~lV3t#m&)`-RBIwB1(0>HcVK;+(!x?6|cgfA%S*oQW$IeT!?H2jl#eRmUcuBM#W>T@w*O>!TR%VUFAq^x5c@hkk6wtrL3jAik-zm`1Z>w3dF z(ILlwCRBY&Ds_w_|10&0@DxUn??w3qb%pSjzZL(gi}F^^j&e&T%*9UCz)j>r ztZyXoCC*@SItJog&QX-tkzZTQpXMV(aKt*j!$3N!Sgi67)az)^Io9&aD92L%CN7{( z`sfhrrB-XmPPZO8@E-A_xH5GjeP=DppEdkP95u(S+?u*cWS0=c#|hLl77ADH`d16W_4AGf21Ejf_>fzl8I~)H?STR{8`fb3de93vbt|+H;?>> zp=cKwOn(b)Y*ruvaNgvz3vH>lK) z;5>}}A#o1MN62-;%bZKd58*uX?;w2KX!kyKXE{&At*V1Culyg&_$oaiZ@s!SKDKDc=M+OEdBmXl&U&>Sd6F;DB z7TW2Lt`)E-r;c4V=3Lr7P2^Q^S^jy7^QaB*on^ECXTZPcxRJ9nX9D$uI0rCE$+&CH zBYZWi<=@oyB|E@I5V6GXS^XvAD9&OQKf(jl=fh+yF%{<%#?Z0G{Dk`R#Q#{Vx~uvJ zM+y3kO630?i6znSJ8M*cioy)`Q(W(Q=|iuOEkG0*H=|y9-(lC+|IhZc8&1DB$6(?x zS|;aAMLv?ANAWOLCRd4aXBwxZ-4@ETD1VI0iFItE9EsI2hEqo;^KHtj0{L;8k%4ww zhwn+Arok-E7o3TiD7n=QQ*Vw7|<6`Sa`j(Oz&e@Bzfz|Gy*CfiBX!;GNXBHi& z7X=N$ z@0|Q$!mB~!=X9<`z83ZGxlZ0>%6V;3`SB0Se@Xr(ZQBt~qr8~Dm9eI^O-9{9;tia? zQ|_-H)2(Rqhz@%>=Tfng^J8*4?%2S|@c?I1>gJIDl=^PgX1TQqqb|ZG`jqk++GU|` zDD5_LhI3{iuj3=mO0;dLUrzMva$U|?8%Tfff1h$!Ivm2+mS2VW$(6H7np&P;=DeEZ z6Np#i8P1tjR~;KMc4y8HIPVdUq`i)}iQ|Sh4Dk&kS(xB6&J2{}8K4>tBz|*LAomlw zwx}ZyEXE$S8p^6w?;P)JMTUDjYc zwxQ7sa%XIS+>}ppj*2VUDAKo?EPrF*Ki;E!j`Ij-YTKI?)XgLg!B1#ak)AsEgPOOB zd`Hfg#5Yk#W$Lq04nz$xp7NL0NA`0)FW40OmsDI>n3t|@iL zI72Ca$T^F1L|ka&9Mvn)>MW6tAX?|a4=i3tkIdvAlY2@o8RY_2*A*+1Z^l_Fu6N@I zUwuk%jwTd-rFCh}FDbvxNCP!cukbeI?(%8jbuw*{s z)wIdUsXxhbxOaCFiJxuof;1{id4R>gVGSnGF$7x^H?X>BEMk-9q+cK6C&aU?J_+rs z((fo|u(dm;fjFko=9K?eY%0o%DVUe@9H;)uBZ6}Row}->O|X~Z5zcy? zpK-3?{J`oy!?$e*S~JE{;`ixWf^xXcRa@`v94fldaRjH1+vHY|%lxJ@xeml%&{2o} zwo%6+8zewiPFx80V_NbntxkU~e?i|yxQN_g&dWB=7V_=8^N%W2HsI8;+~Hj!U(CwA zDF<*qAzp_%_T#$@_OlDU#h8u-oF|`%33B3A929r0Y5K%p6P=9vyJ>M>XG-65{zm)R z*0(nCo1+0^F6A6TVk~DS>S{CYYs#BBf2U4IG`U-pZ!^bY&e1kTQt}&!`%%}=a`o|! zEBn09U4Q>SU(#@n4cL-Q7-t7^-&x1YIFnp;&YsrpXX=|#zRtOb{4UxAG3i?ClY?>~ zZFD@NO-u5{$W^4A(`)A{np6K7>fI@00@+Fhh>pZ~X~#AN=a<^-A4)Lf#+ ze9o`coFfJ0H%Bw-$I$K_axd}3f9jUdXfHV(H)+>~atqWef6|}jy`lZvoXa@(a25=t z`Wc1uoEvCd5Z|ToJjyF6SK`!>l=GIAr!sJM&ilkaOHLhEIJ;PR0_FLP@eXGsr;aG< zvU9N=iQlDfQOv64{Yb@!G-$(l)*8>Yfo@n_hq{||>P`Mnt0U#DrM&Gw@iyAW(e44~ zntpEmW6%(pDlR<@{2f=)2zerd{|hvg6LIljG-_^*)~FNbed-3g#@;wn9Jk5dq{>q`#94`aSsP;x zreln;oDt-Iq`nTm#d+G_F*ASZMWv4CG~R;sEFKouyLH67-;zyFa~)G0UPf{g$<3f; z9A|mrYS^3ll$75b7b#cgvg+8gQX=j&|Ha9G_*<=8r;cyeqim9}*d86C8aJ*LRi}E( z_}Tpihs0MNmOLsD6Du>eMN=PrPq_ zOtM&;II34{pN>8IbdTyA+pph8Jvw=fVte=hs84KEuQv_)^zTOW|7+E^U!V5&CxB!n?DAf0vnl=cJ%8x8$hq zJv+q4FW$R6EI#zutaS0)uPscR;?BN3w~-QL@K z)_Hdq48OBtgj?R7m7{NspZC93-(EZC_Uz^Vt)5#sLqN9N{t?(dwfe$Kxc*JE4r z3<2Sx+r~r(ln4&LwQhdG+^u)q(zfRbXdM*pf`l)Z+}b^DTk3)V3&OVbFB1@+\n" "Language-Team: JumpServer team\n" @@ -20,7 +20,7 @@ msgstr "" #: acls/models/base.py:25 acls/serializers/login_asset_acl.py:47 #: applications/models/application.py:166 assets/models/asset.py:139 #: assets/models/base.py:175 assets/models/cluster.py:18 -#: assets/models/cmd_filter.py:21 assets/models/domain.py:21 +#: assets/models/cmd_filter.py:21 assets/models/domain.py:24 #: assets/models/group.py:20 assets/models/label.py:18 ops/mixin.py:24 #: orgs/models.py:24 perms/models/base.py:49 settings/models.py:29 #: terminal/models/storage.py:23 terminal/models/task.py:16 @@ -56,12 +56,12 @@ msgstr "激活中" #: assets/models/asset.py:144 assets/models/asset.py:220 #: assets/models/base.py:180 assets/models/cluster.py:29 #: assets/models/cmd_filter.py:23 assets/models/cmd_filter.py:64 -#: assets/models/domain.py:22 assets/models/domain.py:53 +#: assets/models/domain.py:25 assets/models/domain.py:65 #: assets/models/group.py:23 assets/models/label.py:23 ops/models/adhoc.py:37 #: orgs/models.py:27 perms/models/base.py:57 settings/models.py:34 #: terminal/models/storage.py:26 terminal/models/terminal.py:114 #: tickets/models/ticket.py:73 users/models/group.py:16 -#: users/models/user.py:589 xpack/plugins/change_auth_plan/models.py:87 +#: users/models/user.py:589 xpack/plugins/change_auth_plan/models.py:77 #: xpack/plugins/cloud/models.py:35 xpack/plugins/cloud/models.py:108 #: xpack/plugins/gathered_user/models.py:26 msgid "Comment" @@ -92,14 +92,14 @@ msgstr "动作" #: acls/models/login_acl.py:28 acls/models/login_asset_acl.py:20 #: acls/serializers/login_acl.py:33 assets/models/label.py:15 -#: audits/models.py:36 audits/models.py:56 audits/models.py:69 +#: audits/models.py:36 audits/models.py:56 audits/models.py:74 #: audits/serializers.py:93 authentication/models.py:44 #: authentication/models.py:97 orgs/models.py:19 orgs/models.py:433 #: perms/models/base.py:50 templates/index.html:78 #: terminal/backends/command/models.py:18 #: terminal/backends/command/serializers.py:12 terminal/models/session.py:38 #: tickets/models/comment.py:17 users/models/user.py:176 -#: users/models/user.py:752 users/models/user.py:778 +#: users/models/user.py:757 users/models/user.py:783 #: users/serializers/group.py:19 #: users/templates/users/user_asset_permission.html:38 #: users/templates/users/user_asset_permission.html:64 @@ -118,7 +118,7 @@ msgid "System User" msgstr "系统用户" #: acls/models/login_asset_acl.py:22 -#: applications/serializers/attrs/application_category/remote_app.py:33 +#: applications/serializers/attrs/application_category/remote_app.py:37 #: assets/models/asset.py:357 assets/models/authbook.py:15 #: assets/models/gathered_user.py:14 assets/serializers/system_user.py:200 #: audits/models.py:38 perms/models/asset_permission.py:99 @@ -126,7 +126,7 @@ msgstr "系统用户" #: terminal/backends/command/serializers.py:13 terminal/models/session.py:40 #: users/templates/users/user_asset_permission.html:40 #: users/templates/users/user_asset_permission.html:70 -#: xpack/plugins/change_auth_plan/models.py:320 +#: xpack/plugins/change_auth_plan/models.py:282 #: xpack/plugins/cloud/models.py:212 msgid "Asset" msgstr "资产" @@ -155,8 +155,8 @@ msgstr "" #: acls/serializers/login_acl.py:30 acls/serializers/login_asset_acl.py:31 #: applications/serializers/attrs/application_type/mysql_workbench.py:18 -#: assets/models/asset.py:180 assets/models/domain.py:49 -#: assets/serializers/account.py:12 settings/serializers/settings.py:113 +#: assets/models/asset.py:180 assets/models/domain.py:61 +#: assets/serializers/account.py:12 settings/serializers/settings.py:114 #: users/templates/users/_granted_assets.html:26 #: users/templates/users/user_asset_permission.html:156 msgid "IP" @@ -178,11 +178,11 @@ msgstr "格式为逗号分隔的字符串, * 表示匹配所有. " #: applications/serializers/attrs/application_type/mysql_workbench.py:30 #: applications/serializers/attrs/application_type/vmware_client.py:26 #: assets/models/base.py:176 assets/models/gathered_user.py:15 -#: audits/models.py:100 authentication/forms.py:15 authentication/forms.py:17 +#: audits/models.py:105 authentication/forms.py:15 authentication/forms.py:17 #: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:554 #: users/templates/users/_select_user_modal.html:14 -#: xpack/plugins/change_auth_plan/models.py:50 -#: xpack/plugins/change_auth_plan/models.py:316 +#: xpack/plugins/change_auth_plan/models.py:47 +#: xpack/plugins/change_auth_plan/models.py:278 #: xpack/plugins/cloud/serializers.py:67 msgid "Username" msgstr "用户名" @@ -198,7 +198,7 @@ msgstr "" #: acls/serializers/login_asset_acl.py:35 assets/models/asset.py:181 #: assets/serializers/account.py:13 assets/serializers/gathered_user.py:23 -#: settings/serializers/settings.py:112 +#: settings/serializers/settings.py:113 #: users/templates/users/_granted_assets.html:25 #: users/templates/users/user_asset_permission.html:157 msgid "Hostname" @@ -211,7 +211,7 @@ msgid "" msgstr "格式为逗号分隔的字符串, * 表示匹配所有. 可选的协议有: {}" #: acls/serializers/login_asset_acl.py:55 assets/models/asset.py:184 -#: assets/models/domain.py:51 assets/models/user.py:204 +#: assets/models/domain.py:63 assets/models/user.py:204 #: terminal/serializers/session.py:30 terminal/serializers/storage.py:69 msgid "Protocol" msgstr "协议" @@ -270,7 +270,7 @@ msgid "Type" msgstr "类型" #: applications/models/application.py:175 assets/models/asset.py:188 -#: assets/models/domain.py:27 assets/models/domain.py:52 +#: assets/models/domain.py:30 assets/models/domain.py:64 msgid "Domain" msgstr "网域" @@ -303,13 +303,13 @@ msgstr "类型名称" #: assets/models/base.py:177 audits/signals_handler.py:63 #: authentication/forms.py:22 #: authentication/templates/authentication/login.html:164 -#: settings/serializers/settings.py:94 users/forms/profile.py:21 +#: settings/serializers/settings.py:95 users/forms/profile.py:21 #: users/templates/users/user_otp_check_password.html:13 #: users/templates/users/user_password_update.html:43 #: users/templates/users/user_password_verify.html:18 -#: xpack/plugins/change_auth_plan/models.py:71 -#: xpack/plugins/change_auth_plan/models.py:212 -#: xpack/plugins/change_auth_plan/models.py:323 +#: xpack/plugins/change_auth_plan/models.py:68 +#: xpack/plugins/change_auth_plan/models.py:190 +#: xpack/plugins/change_auth_plan/models.py:285 #: xpack/plugins/cloud/serializers.py:69 msgid "Password" msgstr "密码" @@ -360,12 +360,12 @@ msgstr "主机" #: applications/serializers/attrs/application_type/mysql_workbench.py:22 #: applications/serializers/attrs/application_type/oracle.py:11 #: applications/serializers/attrs/application_type/pgsql.py:11 -#: assets/models/asset.py:185 assets/models/domain.py:50 +#: assets/models/asset.py:185 assets/models/domain.py:62 #: xpack/plugins/cloud/serializers.py:66 msgid "Port" msgstr "端口" -#: applications/serializers/attrs/application_category/remote_app.py:36 +#: applications/serializers/attrs/application_category/remote_app.py:40 #: applications/serializers/attrs/application_type/chrome.py:14 #: applications/serializers/attrs/application_type/mysql_workbench.py:14 #: applications/serializers/attrs/application_type/vmware_client.py:18 @@ -431,13 +431,13 @@ msgstr "协议组" #: assets/models/asset.py:189 assets/models/user.py:194 #: perms/models/asset_permission.py:100 -#: xpack/plugins/change_auth_plan/models.py:59 +#: xpack/plugins/change_auth_plan/models.py:56 #: xpack/plugins/gathered_user/models.py:24 msgid "Nodes" msgstr "节点" #: assets/models/asset.py:190 assets/models/cmd_filter.py:22 -#: assets/models/domain.py:54 assets/models/label.py:22 +#: assets/models/domain.py:66 assets/models/label.py:22 #: authentication/models.py:46 msgid "Is active" msgstr "激活" @@ -521,7 +521,7 @@ msgstr "标签管理" #: assets/models/cmd_filter.py:67 assets/models/group.py:21 #: common/db/models.py:70 common/mixins/models.py:49 orgs/models.py:25 #: orgs/models.py:437 perms/models/base.py:55 users/models/user.py:597 -#: users/serializers/group.py:33 xpack/plugins/change_auth_plan/models.py:91 +#: users/serializers/group.py:33 xpack/plugins/change_auth_plan/models.py:81 #: xpack/plugins/cloud/models.py:114 xpack/plugins/gathered_user/models.py:30 msgid "Created by" msgstr "创建者" @@ -529,12 +529,12 @@ msgstr "创建者" # msgid "Created by" # msgstr "创建者" #: assets/models/asset.py:219 assets/models/base.py:181 -#: assets/models/cluster.py:26 assets/models/domain.py:24 +#: assets/models/cluster.py:26 assets/models/domain.py:27 #: assets/models/gathered_user.py:19 assets/models/group.py:22 #: assets/models/label.py:25 common/db/models.py:72 common/mixins/models.py:50 #: ops/models/adhoc.py:38 ops/models/command.py:29 orgs/models.py:26 #: orgs/models.py:435 perms/models/base.py:56 users/models/group.py:18 -#: users/models/user.py:779 xpack/plugins/cloud/models.py:117 +#: users/models/user.py:784 xpack/plugins/cloud/models.py:117 msgid "Date created" msgstr "创建日期" @@ -554,7 +554,8 @@ msgstr "未知" msgid "Ok" msgstr "成功" -#: assets/models/base.py:32 audits/models.py:97 xpack/plugins/cloud/const.py:27 +#: assets/models/base.py:32 audits/models.py:102 +#: xpack/plugins/cloud/const.py:27 msgid "Failed" msgstr "失败" @@ -566,15 +567,15 @@ msgstr "可连接性" msgid "Date verified" msgstr "校验日期" -#: assets/models/base.py:178 xpack/plugins/change_auth_plan/models.py:81 -#: xpack/plugins/change_auth_plan/models.py:219 -#: xpack/plugins/change_auth_plan/models.py:330 +#: assets/models/base.py:178 xpack/plugins/change_auth_plan/models.py:72 +#: xpack/plugins/change_auth_plan/models.py:197 +#: xpack/plugins/change_auth_plan/models.py:292 msgid "SSH private key" msgstr "SSH密钥" -#: assets/models/base.py:179 xpack/plugins/change_auth_plan/models.py:84 -#: xpack/plugins/change_auth_plan/models.py:215 -#: xpack/plugins/change_auth_plan/models.py:326 +#: assets/models/base.py:179 xpack/plugins/change_auth_plan/models.py:75 +#: xpack/plugins/change_auth_plan/models.py:193 +#: xpack/plugins/change_auth_plan/models.py:288 msgid "SSH public key" msgstr "SSH公钥" @@ -618,7 +619,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:764 +#: users/models/user.py:769 msgid "System" msgstr "系统" @@ -667,16 +668,16 @@ msgstr "命令过滤规则" msgid "Command confirm" msgstr "命令复核" -#: assets/models/domain.py:61 +#: assets/models/domain.py:73 msgid "Gateway" msgstr "网关" -#: assets/models/domain.py:84 +#: assets/models/domain.py:127 #, python-brace-format msgid "Unable to connect to port {port} on {ip}" msgstr "无法连接到 {ip} 上的端口 {port}" -#: assets/models/domain.py:87 +#: assets/models/domain.py:130 msgid "Authentication failed" msgstr "认证失败" @@ -753,7 +754,7 @@ msgid "Username same with user" msgstr "用户名与用户相同" #: assets/models/user.py:196 assets/serializers/domain.py:28 -#: templates/_nav.html:39 xpack/plugins/change_auth_plan/models.py:55 +#: templates/_nav.html:39 xpack/plugins/change_auth_plan/models.py:52 msgid "Assets" msgstr "资产" @@ -956,7 +957,7 @@ msgstr "更新资产硬件信息: {}" msgid "Update node asset hardware information: {}" msgstr "更新节点资产硬件信息: {}" -#: assets/tasks/gather_asset_users.py:108 +#: assets/tasks/gather_asset_users.py:111 msgid "Gather assets users" msgstr "收集资产上的用户" @@ -1053,7 +1054,7 @@ msgstr "创建目录" msgid "Symlink" msgstr "建立软链接" -#: audits/models.py:37 audits/models.py:60 audits/models.py:71 +#: audits/models.py:37 audits/models.py:60 audits/models.py:76 #: terminal/models/session.py:45 msgid "Remote addr" msgstr "远端地址" @@ -1066,7 +1067,7 @@ msgstr "操作" msgid "Filename" msgstr "文件名" -#: audits/models.py:42 audits/models.py:96 +#: audits/models.py:42 audits/models.py:101 msgid "Success" msgstr "成功" @@ -1076,8 +1077,8 @@ msgstr "成功" #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:74 #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:40 #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:78 -#: xpack/plugins/change_auth_plan/models.py:199 -#: xpack/plugins/change_auth_plan/models.py:345 +#: xpack/plugins/change_auth_plan/models.py:177 +#: xpack/plugins/change_auth_plan/models.py:307 #: xpack/plugins/gathered_user/models.py:76 msgid "Date start" msgstr "开始日期" @@ -1102,45 +1103,45 @@ msgstr "资源类型" msgid "Resource" msgstr "资源" -#: audits/models.py:61 audits/models.py:72 +#: audits/models.py:61 audits/models.py:77 msgid "Datetime" msgstr "日期" -#: audits/models.py:70 +#: audits/models.py:75 msgid "Change by" msgstr "修改者" -#: audits/models.py:90 +#: audits/models.py:95 msgid "Disabled" msgstr "禁用" -#: audits/models.py:91 settings/models.py:33 +#: audits/models.py:96 settings/models.py:33 msgid "Enabled" msgstr "启用" -#: audits/models.py:92 +#: audits/models.py:97 msgid "-" msgstr "" -#: audits/models.py:101 +#: audits/models.py:106 msgid "Login type" msgstr "登录方式" -#: audits/models.py:102 +#: audits/models.py:107 #: tickets/serializers/ticket/meta/ticket_type/login_confirm.py:14 msgid "Login ip" msgstr "登录IP" -#: audits/models.py:103 +#: audits/models.py:108 #: tickets/serializers/ticket/meta/ticket_type/login_confirm.py:17 msgid "Login city" msgstr "登录城市" -#: audits/models.py:104 audits/serializers.py:44 +#: audits/models.py:109 audits/serializers.py:44 msgid "User agent" msgstr "用户代理" -#: audits/models.py:105 +#: audits/models.py:110 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 #: authentication/templates/authentication/login_otp.html:6 #: users/forms/profile.py:64 users/models/user.py:578 @@ -1148,21 +1149,21 @@ msgstr "用户代理" msgid "MFA" msgstr "多因子认证" -#: audits/models.py:106 xpack/plugins/change_auth_plan/models.py:341 +#: audits/models.py:111 xpack/plugins/change_auth_plan/models.py:303 #: xpack/plugins/cloud/models.py:171 msgid "Reason" msgstr "原因" -#: audits/models.py:107 tickets/models/ticket.py:47 +#: audits/models.py:112 tickets/models/ticket.py:47 #: xpack/plugins/cloud/models.py:167 xpack/plugins/cloud/models.py:216 msgid "Status" msgstr "状态" -#: audits/models.py:108 +#: audits/models.py:113 msgid "Date login" msgstr "登录日期" -#: audits/models.py:109 +#: audits/models.py:114 msgid "Authentication backend" msgstr "认证方式" @@ -1222,13 +1223,13 @@ msgstr "认证令牌" #: audits/signals_handler.py:66 #: authentication/templates/authentication/login.html:210 -#: notifications/backends/__init__.py:12 +#: notifications/backends/__init__.py:13 msgid "WeCom" msgstr "企业微信" #: audits/signals_handler.py:67 #: authentication/templates/authentication/login.html:215 -#: notifications/backends/__init__.py:13 +#: notifications/backends/__init__.py:14 msgid "DingTalk" msgstr "钉钉" @@ -1401,7 +1402,7 @@ msgstr "{ApplicationPermission} *添加了* {SystemUser}" msgid "{ApplicationPermission} *REMOVE* {SystemUser}" msgstr "{ApplicationPermission} *移除了* {SystemUser}" -#: authentication/api/connection_token.py:268 +#: authentication/api/connection_token.py:222 msgid "Invalid token" msgstr "无效的令牌" @@ -1577,7 +1578,7 @@ msgstr "登录完成前,请先修改密码" msgid "Your password has expired, please reset before logging in" msgstr "您的密码已过期,先修改再登录" -#: authentication/errors.py:320 +#: authentication/errors.py:325 msgid "Your password is invalid" msgstr "您的密码无效" @@ -1633,7 +1634,7 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: settings/serializers/settings.py:148 users/models/user.py:463 +#: settings/serializers/settings.py:149 users/models/user.py:463 #: users/serializers/profile.py:99 #: users/templates/users/user_verify_mfa.html:32 msgid "Disable" @@ -1707,6 +1708,11 @@ msgstr "OpenID" msgid "CAS" msgstr "CAS" +#: authentication/templates/authentication/login.html:220 +#: notifications/backends/__init__.py:16 +msgid "FeiShu" +msgstr "" + #: authentication/templates/authentication/login_otp.html:17 msgid "One-time password" msgstr "一次性密码" @@ -1752,7 +1758,8 @@ msgstr "钉钉错误,请联系系统管理员" msgid "DingTalk Error" msgstr "钉钉错误" -#: authentication/views/dingtalk.py:56 authentication/views/wecom.py:56 +#: authentication/views/dingtalk.py:56 authentication/views/feishu.py:55 +#: authentication/views/wecom.py:56 msgid "You've been hacked" msgstr "你被攻击了" @@ -1760,7 +1767,8 @@ msgstr "你被攻击了" msgid "DingTalk is already bound" msgstr "钉钉已经绑定" -#: authentication/views/dingtalk.py:105 authentication/views/wecom.py:104 +#: authentication/views/dingtalk.py:105 authentication/views/feishu.py:102 +#: authentication/views/wecom.py:104 msgid "Please verify your password first" msgstr "请检查密码" @@ -1789,7 +1797,8 @@ msgstr "从钉钉获取用户失败" msgid "DingTalk is not bound" msgstr "钉钉没有绑定" -#: authentication/views/dingtalk.py:218 authentication/views/wecom.py:216 +#: authentication/views/dingtalk.py:218 authentication/views/feishu.py:208 +#: authentication/views/wecom.py:216 msgid "Please login with a password and then bind the WeCom" msgstr "请使用密码登录,然后绑定企业微信" @@ -1797,6 +1806,43 @@ msgstr "请使用密码登录,然后绑定企业微信" msgid "Binding DingTalk failed" msgstr "绑定钉钉失败" +#: authentication/views/feishu.py:40 +msgid "FeiShu Error, Please contact your system administrator" +msgstr "飞书错误,请联系系统管理员" + +#: authentication/views/feishu.py:43 +msgid "FeiShu Error" +msgstr "飞书错误" + +#: authentication/views/feishu.py:89 +msgid "FeiShu is already bound" +msgstr "飞书已经绑定" + +#: authentication/views/feishu.py:136 +msgid "FeiShu query user failed" +msgstr "飞书查询用户失败" + +#: authentication/views/feishu.py:145 +msgid "The FeiShu is already bound to another user" +msgstr "该飞书已经绑定其他用户" + +#: authentication/views/feishu.py:150 authentication/views/feishu.py:232 +#: authentication/views/feishu.py:233 +msgid "Binding FeiShu successfully" +msgstr "绑定 飞书 成功" + +#: authentication/views/feishu.py:201 +msgid "Failed to get user from FeiShu" +msgstr "从飞书获取用户失败" + +#: authentication/views/feishu.py:207 +msgid "FeiShu is not bound" +msgstr "没有绑定飞书" + +#: authentication/views/feishu.py:250 authentication/views/feishu.py:251 +msgid "Binding FeiShu failed" +msgstr "绑定飞书失败" + #: authentication/views/login.py:78 msgid "Redirecting" msgstr "跳转中" @@ -1809,7 +1855,7 @@ msgstr "正在跳转到 {} 认证" msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: authentication/views/login.py:223 +#: authentication/views/login.py:224 msgid "" "Wait for {} confirm, You also can copy link to her/him
\n" " Don't close this page" @@ -1817,15 +1863,15 @@ msgstr "" "等待 {} 确认, 你也可以复制链接发给他/她
\n" " 不要关闭本页面" -#: authentication/views/login.py:228 +#: authentication/views/login.py:229 msgid "No ticket found" msgstr "没有发现工单" -#: authentication/views/login.py:260 +#: authentication/views/login.py:261 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:261 +#: authentication/views/login.py:262 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" @@ -1949,7 +1995,7 @@ msgstr "加密的字段" msgid "Network error, please contact system administrator" msgstr "网络错误,请联系系统管理员" -#: common/message/backends/wecom/__init__.py:19 +#: common/message/backends/wecom/__init__.py:15 msgid "WeCom error, please contact system administrator" msgstr "企业微信错误,请联系系统管理员" @@ -1981,7 +2027,7 @@ msgstr "字段必须唯一" msgid "Should not contains special characters" msgstr "不能包含特殊字符" -#: jumpserver/context_processor.py:19 +#: jumpserver/context_processor.py:20 msgid "JumpServer Open Source Bastion Host" msgstr "JumpServer 开源堡垒机" @@ -2012,12 +2058,12 @@ msgstr "" "div>
如果你看到了这个页面,证明你访问的不是nginx监听的端口,祝你好运" -#: notifications/backends/__init__.py:11 users/forms/profile.py:101 +#: notifications/backends/__init__.py:12 users/forms/profile.py:101 #: users/models/user.py:558 msgid "Email" msgstr "邮件" -#: notifications/backends/__init__.py:14 +#: notifications/backends/__init__.py:15 msgid "Site message" msgstr "站内信" @@ -2029,7 +2075,7 @@ msgstr "等待任务开始" msgid "Not has host {} permission" msgstr "没有该主机 {} 权限" -#: ops/apps.py:9 ops/notifications.py:16 +#: ops/apps.py:9 ops/notifications.py:14 msgid "Operations" msgstr "运维" @@ -2042,7 +2088,7 @@ msgid "Regularly perform" msgstr "定期执行" #: ops/mixin.py:106 ops/mixin.py:147 -#: xpack/plugins/change_auth_plan/serializers.py:59 +#: xpack/plugins/change_auth_plan/serializers.py:55 msgid "Periodic perform" msgstr "定时执行" @@ -2121,8 +2167,8 @@ msgstr "开始时间" msgid "End time" msgstr "完成时间" -#: ops/models/adhoc.py:246 xpack/plugins/change_auth_plan/models.py:202 -#: xpack/plugins/change_auth_plan/models.py:348 +#: ops/models/adhoc.py:246 xpack/plugins/change_auth_plan/models.py:180 +#: xpack/plugins/change_auth_plan/models.py:310 #: xpack/plugins/gathered_user/models.py:79 msgid "Time" msgstr "时间" @@ -2156,31 +2202,26 @@ msgstr "命令 `{}` 不允许被执行 ......." msgid "Task end" msgstr "任务结束" -#: ops/notifications.py:17 +#: ops/notifications.py:15 msgid "Server performance" msgstr "监控告警" -#: ops/notifications.py:86 -msgid "The terminal is offline: {}" +#: ops/notifications.py:36 +#, python-brace-format +msgid "[Alive] The terminal is offline: {name}" msgstr "" -#: ops/notifications.py:103 -#, fuzzy -#| msgid "Disk used more than 80%: {} => {}" -msgid "Disk used more than {}%: {} => {} ({})" -msgstr "磁盘使用率超过 80%: {} => {}" +#: ops/notifications.py:42 +msgid "[Disk] Disk used more than {max_threshold}%: => {value} ({name})" +msgstr "[Disk] 硬盘使用率超过 {max_threshold}%: => {value} ({name})" -#: ops/notifications.py:128 -#, fuzzy -#| msgid "Disk used more than 80%: {} => {}" -msgid "CPU load more than {}: => {} ({})" -msgstr "磁盘使用率超过 80%: {} => {}" +#: ops/notifications.py:49 +msgid "[Memory] Memory used more than {max_threshold}%: => {value} ({name})" +msgstr "[Memory] 内存使用率超过 {max_threshold}%: => {value} ({name})" -#: ops/notifications.py:142 -#, fuzzy -#| msgid "Disk used more than 80%: {} => {}" -msgid "Memory used more than {}%: => {} ({})" -msgstr "磁盘使用率超过 80%: {} => {}" +#: ops/notifications.py:56 +msgid "[CPU] CPU load more than {max_threshold}: => {value} ({name})" +msgstr "[CPU] CPU 使用率超过 {max_threshold}: => {value} ({name})" #: ops/tasks.py:71 msgid "Clean task history period" @@ -2241,7 +2282,7 @@ msgstr "该授权暂时不能撤销" msgid "Application" msgstr "应用程序" -#: perms/models/asset_permission.py:37 settings/serializers/settings.py:117 +#: perms/models/asset_permission.py:37 settings/serializers/settings.py:118 msgid "All" msgstr "全部" @@ -2371,7 +2412,8 @@ msgstr "邮件已经发送{}, 请检查" msgid "Welcome to the JumpServer open source Bastion Host" msgstr "欢迎使用JumpServer开源堡垒机" -#: settings/api/dingtalk.py:36 settings/api/wecom.py:36 +#: settings/api/dingtalk.py:36 settings/api/feishu.py:35 +#: settings/api/wecom.py:36 msgid "Test success" msgstr "测试成功" @@ -2387,27 +2429,27 @@ msgstr "成功导入 {} 个用户 ( 组织: {} )" msgid "Setting" msgstr "设置" -#: settings/serializers/settings.py:15 +#: settings/serializers/settings.py:16 msgid "Site url" msgstr "当前站点URL" -#: settings/serializers/settings.py:16 +#: settings/serializers/settings.py:17 msgid "eg: http://dev.jumpserver.org:8080" msgstr "如: http://dev.jumpserver.org:8080" -#: settings/serializers/settings.py:20 +#: settings/serializers/settings.py:21 msgid "User guide url" msgstr "用户向导URL" -#: settings/serializers/settings.py:21 +#: settings/serializers/settings.py:22 msgid "User first login update profile done redirect to it" msgstr "用户第一次登录,修改profile后重定向到地址, 可以是 wiki 或 其他说明文档" -#: settings/serializers/settings.py:24 +#: settings/serializers/settings.py:25 msgid "Forgot password url" msgstr "忘记密码URL" -#: settings/serializers/settings.py:25 +#: settings/serializers/settings.py:26 msgid "" "The forgot password url on login page, If you use ldap or cas external " "authentication, you can set it" @@ -2415,138 +2457,138 @@ msgstr "" "登录页面忘记密码URL, 如果使用了 LDAP, OPENID 等外部认证系统,可以自定义用户重" "置密码访问的地址" -#: settings/serializers/settings.py:29 +#: settings/serializers/settings.py:30 msgid "Global organization name" msgstr "全局组织名" -#: settings/serializers/settings.py:30 +#: settings/serializers/settings.py:31 msgid "The name of global organization to display" msgstr "全局组织的显示名称,默认为 全局组织" -#: settings/serializers/settings.py:37 +#: settings/serializers/settings.py:38 msgid "SMTP host" msgstr "SMTP 主机" -#: settings/serializers/settings.py:38 +#: settings/serializers/settings.py:39 msgid "SMTP port" msgstr "SMTP 端口" -#: settings/serializers/settings.py:39 +#: settings/serializers/settings.py:40 msgid "SMTP account" msgstr "SMTP 账号" -#: settings/serializers/settings.py:41 +#: settings/serializers/settings.py:42 msgid "SMTP password" msgstr "SMTP 密码" -#: settings/serializers/settings.py:42 +#: settings/serializers/settings.py:43 msgid "Tips: Some provider use token except password" msgstr "提示:一些邮件提供商需要输入的是授权码" -#: settings/serializers/settings.py:45 +#: settings/serializers/settings.py:46 msgid "Send user" msgstr "发件人" -#: settings/serializers/settings.py:46 +#: settings/serializers/settings.py:47 msgid "Tips: Send mail account, default SMTP account as the send account" msgstr "提示:发送邮件账号,默认使用 SMTP 账号作为发送账号" -#: settings/serializers/settings.py:49 +#: settings/serializers/settings.py:50 msgid "Test recipient" msgstr "测试收件人" -#: settings/serializers/settings.py:50 +#: settings/serializers/settings.py:51 msgid "Tips: Used only as a test mail recipient" msgstr "提示:仅用来作为测试邮件收件人" -#: settings/serializers/settings.py:53 +#: settings/serializers/settings.py:54 msgid "Use SSL" msgstr "使用 SSL" -#: settings/serializers/settings.py:54 +#: settings/serializers/settings.py:55 msgid "If SMTP port is 465, may be select" msgstr "如果SMTP端口是465,通常需要启用 SSL" -#: settings/serializers/settings.py:57 +#: settings/serializers/settings.py:58 msgid "Use TLS" msgstr "使用 TLS" -#: settings/serializers/settings.py:58 +#: settings/serializers/settings.py:59 msgid "If SMTP port is 587, may be select" msgstr "如果SMTP端口是587,通常需要启用 TLS" -#: settings/serializers/settings.py:61 +#: settings/serializers/settings.py:62 msgid "Subject prefix" msgstr "主题前缀" -#: settings/serializers/settings.py:68 +#: settings/serializers/settings.py:69 msgid "Create user email subject" msgstr "邮件主题" -#: settings/serializers/settings.py:69 +#: settings/serializers/settings.py:70 msgid "" "Tips: When creating a user, send the subject of the email (eg:Create account " "successfully)" msgstr "提示: 创建用户时,发送设置密码邮件的主题 (例如: 创建用户成功)" -#: settings/serializers/settings.py:73 +#: settings/serializers/settings.py:74 msgid "Create user honorific" msgstr "邮件的敬语" -#: settings/serializers/settings.py:74 +#: settings/serializers/settings.py:75 msgid "Tips: When creating a user, send the honorific of the email (eg:Hello)" msgstr "提示: 创建用户时,发送设置密码邮件的敬语 (例如: 您好)" -#: settings/serializers/settings.py:78 +#: settings/serializers/settings.py:79 msgid "Create user email content" msgstr "邮件的内容" -#: settings/serializers/settings.py:79 +#: settings/serializers/settings.py:80 msgid "Tips:When creating a user, send the content of the email" msgstr "提示: 创建用户时,发送设置密码邮件的内容" -#: settings/serializers/settings.py:82 +#: settings/serializers/settings.py:83 msgid "Signature" msgstr "署名" -#: settings/serializers/settings.py:83 +#: settings/serializers/settings.py:84 msgid "Tips: Email signature (eg:jumpserver)" msgstr "邮件署名 (如:jumpserver)" -#: settings/serializers/settings.py:91 +#: settings/serializers/settings.py:92 msgid "LDAP server" msgstr "LDAP 地址" -#: settings/serializers/settings.py:91 +#: settings/serializers/settings.py:92 msgid "eg: ldap://localhost:389" msgstr "如: ldap://localhost:389" -#: settings/serializers/settings.py:93 +#: settings/serializers/settings.py:94 msgid "Bind DN" msgstr "绑定 DN" -#: settings/serializers/settings.py:96 +#: settings/serializers/settings.py:97 msgid "User OU" msgstr "用户 OU" -#: settings/serializers/settings.py:97 +#: settings/serializers/settings.py:98 msgid "Use | split multi OUs" msgstr "多个 OU 使用 | 分割" -#: settings/serializers/settings.py:100 +#: settings/serializers/settings.py:101 msgid "User search filter" msgstr "用户过滤器" -#: settings/serializers/settings.py:101 +#: settings/serializers/settings.py:102 #, python-format msgid "Choice may be (cn|uid|sAMAccountName)=%(user)s)" msgstr "可能的选项是(cn或uid或sAMAccountName=%(user)s)" -#: settings/serializers/settings.py:104 +#: settings/serializers/settings.py:105 msgid "User attr map" msgstr "用户属性映射" -#: settings/serializers/settings.py:105 +#: settings/serializers/settings.py:106 msgid "" "User attr map present how to map LDAP user attr to jumpserver, username,name," "email is jumpserver attr" @@ -2554,23 +2596,23 @@ msgstr "" "用户属性映射代表怎样将LDAP中用户属性映射到jumpserver用户上,username, name," "email 是jumpserver的用户需要属性" -#: settings/serializers/settings.py:107 +#: settings/serializers/settings.py:108 msgid "Enable LDAP auth" msgstr "启用 LDAP 认证" -#: settings/serializers/settings.py:118 +#: settings/serializers/settings.py:119 msgid "Auto" msgstr "自动" -#: settings/serializers/settings.py:124 +#: settings/serializers/settings.py:125 msgid "Password auth" msgstr "密码认证" -#: settings/serializers/settings.py:126 +#: settings/serializers/settings.py:127 msgid "Public key auth" msgstr "密钥认证" -#: settings/serializers/settings.py:127 +#: settings/serializers/settings.py:128 msgid "" "Tips: If use other auth method, like AD/LDAP, you should disable this to " "avoid being able to log in after deleting" @@ -2578,19 +2620,19 @@ msgstr "" "提示:如果你使用其它认证方式,如 AD/LDAP,你应该禁用此项,以避免第三方系统删" "除后,还可以登录" -#: settings/serializers/settings.py:130 +#: settings/serializers/settings.py:131 msgid "List sort by" msgstr "资产列表排序" -#: settings/serializers/settings.py:131 +#: settings/serializers/settings.py:132 msgid "List page size" msgstr "资产列表每页数量" -#: settings/serializers/settings.py:133 +#: settings/serializers/settings.py:134 msgid "Session keep duration" msgstr "会话日志保存时间" -#: settings/serializers/settings.py:134 +#: settings/serializers/settings.py:135 msgid "" "Units: days, Session, record, command will be delete if more than duration, " "only in database" @@ -2598,76 +2640,76 @@ msgstr "" "单位:天。 会话、录像、命令记录超过该时长将会被删除(仅影响数据库存储, oss等不" "受影响)" -#: settings/serializers/settings.py:136 +#: settings/serializers/settings.py:137 msgid "Telnet login regex" msgstr "Telnet 成功正则表达式" -#: settings/serializers/settings.py:138 +#: settings/serializers/settings.py:139 msgid "RDP address" msgstr "RDP 地址" -#: settings/serializers/settings.py:141 +#: settings/serializers/settings.py:142 msgid "RDP visit address, eg: dev.jumpserver.org:3389" msgstr "RDP 访问地址, 如: dev.jumpserver.org:3389" -#: settings/serializers/settings.py:149 +#: settings/serializers/settings.py:150 msgid "All users" msgstr "所有用户" -#: settings/serializers/settings.py:150 +#: settings/serializers/settings.py:151 msgid "Only admin users" msgstr "仅管理员" -#: settings/serializers/settings.py:152 +#: settings/serializers/settings.py:153 msgid "Global MFA auth" msgstr "全局启用 MFA 认证" -#: settings/serializers/settings.py:155 +#: settings/serializers/settings.py:156 msgid "Batch command execution" msgstr "批量命令执行" -#: settings/serializers/settings.py:156 +#: settings/serializers/settings.py:157 msgid "Allow user run batch command or not using ansible" msgstr "是否允许用户使用 ansible 执行批量命令" -#: settings/serializers/settings.py:159 +#: settings/serializers/settings.py:160 msgid "Enable terminal register" msgstr "终端注册" -#: settings/serializers/settings.py:160 +#: settings/serializers/settings.py:161 msgid "" "Allow terminal register, after all terminal setup, you should disable this " "for security" msgstr "是否允许终端注册,当所有终端启动后,为了安全应该关闭" -#: settings/serializers/settings.py:164 +#: settings/serializers/settings.py:165 msgid "Limit the number of login failures" msgstr "限制登录失败次数" -#: settings/serializers/settings.py:168 +#: settings/serializers/settings.py:169 msgid "Block logon interval" msgstr "禁止登录时间间隔" -#: settings/serializers/settings.py:169 +#: settings/serializers/settings.py:170 msgid "" "Tip: (unit/minute) if the user has failed to log in for a limited number of " "times, no login is allowed during this time interval." msgstr "" "提示:(单位:分)当用户登录失败次数达到限制后,那么在此时间间隔内禁止登录" -#: settings/serializers/settings.py:173 +#: settings/serializers/settings.py:174 msgid "Connection max idle time" msgstr "连接最大空闲时间" -#: settings/serializers/settings.py:174 +#: settings/serializers/settings.py:175 msgid "If idle time more than it, disconnect connection Unit: minute" msgstr "提示:如果超过该配置没有操作,连接会被断开 (单位:分)" -#: settings/serializers/settings.py:178 +#: settings/serializers/settings.py:179 msgid "User password expiration" msgstr "用户密码过期时间" -#: settings/serializers/settings.py:179 +#: settings/serializers/settings.py:180 msgid "" "Tip: (unit: day) If the user does not update the password during the time, " "the user password will expire failure;The password expiration reminder mail " @@ -2677,60 +2719,64 @@ msgstr "" "提示:(单位:天)如果用户在此期间没有更新密码,用户密码将过期失效; 密码过期" "提醒邮件将在密码过期前5天内由系统(每天)自动发送给用户" -#: settings/serializers/settings.py:183 +#: settings/serializers/settings.py:184 msgid "Number of repeated historical passwords" msgstr "不能设置近几次密码" -#: settings/serializers/settings.py:184 +#: settings/serializers/settings.py:185 msgid "" "Tip: When the user resets the password, it cannot be the previous n " "historical passwords of the user" msgstr "提示:用户重置密码时,不能为该用户前几次使用过的密码" -#: settings/serializers/settings.py:188 +#: settings/serializers/settings.py:189 msgid "Password minimum length" msgstr "密码最小长度" -#: settings/serializers/settings.py:192 +#: settings/serializers/settings.py:193 msgid "Admin user password minimum length" msgstr "管理员密码最小长度" -#: settings/serializers/settings.py:195 +#: settings/serializers/settings.py:196 msgid "Must contain capital" msgstr "必须包含大写字符" -#: settings/serializers/settings.py:197 +#: settings/serializers/settings.py:198 msgid "Must contain lowercase" msgstr "必须包含小写字符" -#: settings/serializers/settings.py:198 +#: settings/serializers/settings.py:199 msgid "Must contain numeric" msgstr "必须包含数字" -#: settings/serializers/settings.py:199 +#: settings/serializers/settings.py:200 msgid "Must contain special" msgstr "必须包含特殊字符" -#: settings/serializers/settings.py:200 +#: settings/serializers/settings.py:201 msgid "Insecure command alert" msgstr "危险命令告警" -#: settings/serializers/settings.py:202 +#: settings/serializers/settings.py:203 msgid "Email recipient" msgstr "邮件收件人" -#: settings/serializers/settings.py:203 +#: settings/serializers/settings.py:204 msgid "Multiple user using , split" msgstr "多个用户,使用 , 分割" -#: settings/serializers/settings.py:211 +#: settings/serializers/settings.py:212 msgid "Enable WeCom Auth" msgstr "启用企业微信认证" -#: settings/serializers/settings.py:218 +#: settings/serializers/settings.py:219 msgid "Enable DingTalk Auth" msgstr "启用钉钉认证" +#: settings/serializers/settings.py:225 +msgid "Enable FeiShu Auth" +msgstr "启用飞书认证" + #: settings/utils/ldap.py:412 msgid "ldap:// or ldaps:// protocol is used." msgstr "使用 ldap:// 或 ldaps:// 协议" @@ -4027,7 +4073,7 @@ msgstr "工单已处理 - {} ({})" msgid "Your ticket has been processed, processor - {}" msgstr "你的工单已被处理, 处理人 - {}" -#: users/api/user.py:215 +#: users/api/user.py:214 msgid "Could not reset self otp, use profile reset instead" msgstr "不能在该页面重置多因子认证, 请去个人信息页面重置" @@ -4151,11 +4197,11 @@ msgstr "最后更新密码日期" msgid "Need update password" msgstr "需要更新密码" -#: users/models/user.py:760 +#: users/models/user.py:765 msgid "Administrator" msgstr "管理员" -#: users/models/user.py:763 +#: users/models/user.py:768 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" @@ -4187,8 +4233,8 @@ msgstr "生成重置密码链接,通过邮件发送给用户" msgid "Set password" msgstr "设置密码" -#: users/serializers/user.py:27 xpack/plugins/change_auth_plan/models.py:64 -#: xpack/plugins/change_auth_plan/serializers.py:31 +#: users/serializers/user.py:27 xpack/plugins/change_auth_plan/models.py:61 +#: xpack/plugins/change_auth_plan/serializers.py:30 msgid "Password strategy" msgstr "密码策略" @@ -4815,150 +4861,93 @@ msgid "Reset password success, return to login page" msgstr "重置密码成功,返回到登录页面" #: xpack/plugins/change_auth_plan/meta.py:9 -#: xpack/plugins/change_auth_plan/models.py:99 -#: xpack/plugins/change_auth_plan/models.py:206 +#: xpack/plugins/change_auth_plan/models.py:89 +#: xpack/plugins/change_auth_plan/models.py:184 msgid "Change auth plan" msgstr "改密计划" -#: xpack/plugins/change_auth_plan/models.py:39 +#: xpack/plugins/change_auth_plan/models.py:41 msgid "Custom password" msgstr "自定义密码" -#: xpack/plugins/change_auth_plan/models.py:40 +#: xpack/plugins/change_auth_plan/models.py:42 msgid "All assets use the same random password" msgstr "所有资产使用相同的随机密码" -#: xpack/plugins/change_auth_plan/models.py:41 +#: xpack/plugins/change_auth_plan/models.py:43 msgid "All assets use different random password" msgstr "所有资产使用不同的随机密码" -#: xpack/plugins/change_auth_plan/models.py:45 -msgid "Append SSH KEY" -msgstr "追加新密钥" - -#: xpack/plugins/change_auth_plan/models.py:46 -msgid "Empty and append SSH KEY" -msgstr "清空所有密钥再追加新密钥" - -#: xpack/plugins/change_auth_plan/models.py:47 -msgid "Empty current user and append SSH KEY" -msgstr "清空当前账号密钥再追加新密钥" - -#: xpack/plugins/change_auth_plan/models.py:68 +#: xpack/plugins/change_auth_plan/models.py:65 msgid "Password rules" msgstr "密码规则" -#: xpack/plugins/change_auth_plan/models.py:77 -#, fuzzy -#| msgid "Hostname strategy" -msgid "SSH key strategy" -msgstr "主机名策略" - -#: xpack/plugins/change_auth_plan/models.py:194 -msgid "Manual trigger" -msgstr "手动触发" - -#: xpack/plugins/change_auth_plan/models.py:195 -msgid "Timing trigger" -msgstr "定时触发" - -#: xpack/plugins/change_auth_plan/models.py:209 +#: xpack/plugins/change_auth_plan/models.py:187 msgid "Change auth plan snapshot" msgstr "改密计划快照" -#: xpack/plugins/change_auth_plan/models.py:223 -#: xpack/plugins/change_auth_plan/serializers.py:163 -msgid "Trigger mode" -msgstr "触发模式" - -#: xpack/plugins/change_auth_plan/models.py:228 -#: xpack/plugins/change_auth_plan/models.py:334 +#: xpack/plugins/change_auth_plan/models.py:202 +#: xpack/plugins/change_auth_plan/models.py:296 msgid "Change auth plan execution" msgstr "改密计划执行" -#: xpack/plugins/change_auth_plan/models.py:307 +#: xpack/plugins/change_auth_plan/models.py:269 msgid "Ready" msgstr "" -#: xpack/plugins/change_auth_plan/models.py:308 +#: xpack/plugins/change_auth_plan/models.py:270 msgid "Preflight check" msgstr "" -#: xpack/plugins/change_auth_plan/models.py:309 +#: xpack/plugins/change_auth_plan/models.py:271 msgid "Change auth" msgstr "" -#: xpack/plugins/change_auth_plan/models.py:310 +#: xpack/plugins/change_auth_plan/models.py:272 msgid "Verify auth" msgstr "" -#: xpack/plugins/change_auth_plan/models.py:311 +#: xpack/plugins/change_auth_plan/models.py:273 msgid "Keep auth" msgstr "" -#: xpack/plugins/change_auth_plan/models.py:312 +#: xpack/plugins/change_auth_plan/models.py:274 msgid "Finished" msgstr "结束" -#: xpack/plugins/change_auth_plan/models.py:338 +#: xpack/plugins/change_auth_plan/models.py:300 msgid "Step" msgstr "步骤" -#: xpack/plugins/change_auth_plan/models.py:355 +#: xpack/plugins/change_auth_plan/models.py:317 msgid "Change auth plan task" msgstr "改密计划任务" -#: xpack/plugins/change_auth_plan/serializers.py:27 -msgid "Change Password" -msgstr "修改密码" - -#: xpack/plugins/change_auth_plan/serializers.py:28 -msgid "Change SSH Key" -msgstr "修改密钥" - -#: xpack/plugins/change_auth_plan/serializers.py:33 -#, fuzzy -#| msgid "SSH Key Reset" -msgid "SSH Key strategy" -msgstr "重置SSH密钥" - -#: xpack/plugins/change_auth_plan/serializers.py:60 +#: xpack/plugins/change_auth_plan/serializers.py:56 msgid "Run times" msgstr "执行次数" -#: xpack/plugins/change_auth_plan/serializers.py:78 -msgid "Require password strategy perform setting" -msgstr "需要密码策略执行设置" +#: xpack/plugins/change_auth_plan/serializers.py:72 +msgid "* Please enter custom password" +msgstr "* 请输入自定义密码" -#: xpack/plugins/change_auth_plan/serializers.py:81 -msgid "Require password perform setting" -msgstr "需要密码执行设置" - -#: xpack/plugins/change_auth_plan/serializers.py:84 -msgid "Require password rule perform setting" -msgstr "需要密码规则执行设置" - -#: xpack/plugins/change_auth_plan/serializers.py:96 +#: xpack/plugins/change_auth_plan/serializers.py:82 msgid "* Please enter the correct password length" msgstr "* 请输入正确的密码长度" -#: xpack/plugins/change_auth_plan/serializers.py:99 +#: xpack/plugins/change_auth_plan/serializers.py:85 msgid "* Password length range 6-30 bits" msgstr "* 密码长度范围 6-30 位" -#: xpack/plugins/change_auth_plan/serializers.py:118 -msgid "Require ssh key strategy or ssh key perform setting" -msgstr "需要ssh密钥策略或ssh密钥执行设置" - -#: xpack/plugins/change_auth_plan/utils.py:485 +#: xpack/plugins/change_auth_plan/utils.py:442 msgid "Invalid/incorrect password" msgstr "无效/错误 密码" -#: xpack/plugins/change_auth_plan/utils.py:487 +#: xpack/plugins/change_auth_plan/utils.py:444 msgid "Failed to connect to the host" msgstr "连接主机失败" -#: xpack/plugins/change_auth_plan/utils.py:489 +#: xpack/plugins/change_auth_plan/utils.py:446 msgid "Data could not be sent to remote" msgstr "无法将数据发送到远程" @@ -5380,8 +5369,51 @@ msgstr "旗舰版" msgid "Community edition" msgstr "社区版" -#~ msgid "* Please enter custom password" -#~ msgstr "* 请输入自定义密码" +#~ msgid "Append SSH KEY" +#~ msgstr "追加新密钥" + +#~ msgid "Empty and append SSH KEY" +#~ msgstr "清空所有密钥再追加新密钥" + +#~ msgid "Empty current user and append SSH KEY" +#~ msgstr "清空当前账号密钥再追加新密钥" + +#, fuzzy +#~| msgid "Hostname strategy" +#~ msgid "SSH key strategy" +#~ msgstr "主机名策略" + +#~ msgid "Manual trigger" +#~ msgstr "手动触发" + +#~ msgid "Timing trigger" +#~ msgstr "定时触发" + +#~ msgid "Trigger mode" +#~ msgstr "触发模式" + +#~ msgid "Change Password" +#~ msgstr "修改密码" + +#~ msgid "Change SSH Key" +#~ msgstr "修改密钥" + +#, fuzzy +#~| msgid "SSH Key Reset" +#~ msgid "SSH Key strategy" +#~ msgstr "重置SSH密钥" + +#~ msgid "Require password strategy perform setting" +#~ msgstr "需要密码策略执行设置" + +#~ msgid "Require password perform setting" +#~ msgstr "需要密码执行设置" + +#~ msgid "Require password rule perform setting" +#~ msgstr "需要密码规则执行设置" + +#~ msgid "Require ssh key strategy or ssh key perform setting" +#~ msgstr "需要ssh密钥策略或ssh密钥执行设置" #~ msgid "Category(Display)" #~ msgstr "类别 (显示名称)" diff --git a/apps/notifications/backends/__init__.py b/apps/notifications/backends/__init__.py index 4e2633072..11a95cf40 100644 --- a/apps/notifications/backends/__init__.py +++ b/apps/notifications/backends/__init__.py @@ -5,6 +5,7 @@ from .dingtalk import DingTalk from .email import Email from .site_msg import SiteMessage from .wecom import WeCom +from .feishu import FeiShu class BACKEND(models.TextChoices): @@ -12,6 +13,7 @@ class BACKEND(models.TextChoices): WECOM = 'wecom', _('WeCom') DINGTALK = 'dingtalk', _('DingTalk') SITE_MSG = 'site_msg', _('Site message') + FEISHU = 'feishu', _('FeiShu') @property def client(self): @@ -19,7 +21,8 @@ class BACKEND(models.TextChoices): self.EMAIL: Email, self.WECOM: WeCom, self.DINGTALK: DingTalk, - self.SITE_MSG: SiteMessage + self.SITE_MSG: SiteMessage, + self.FEISHU: FeiShu, }[self] return client diff --git a/apps/notifications/backends/dingtalk.py b/apps/notifications/backends/dingtalk.py index ef5e9a9c6..83add673e 100644 --- a/apps/notifications/backends/dingtalk.py +++ b/apps/notifications/backends/dingtalk.py @@ -1,5 +1,4 @@ from django.conf import settings - from common.message.backends.dingtalk import DingTalk as Client from .base import BackendBase diff --git a/apps/notifications/backends/feishu.py b/apps/notifications/backends/feishu.py new file mode 100644 index 000000000..90547299c --- /dev/null +++ b/apps/notifications/backends/feishu.py @@ -0,0 +1,19 @@ +from django.conf import settings + +from common.message.backends.feishu import FeiShu as Client +from .base import BackendBase + + +class FeiShu(BackendBase): + account_field = 'feishu_id' + is_enable_field_in_settings = 'AUTH_FEISHU' + + def __init__(self): + self.client = Client( + app_id=settings.FEISHU_APP_ID, + app_secret=settings.FEISHU_APP_SECRET + ) + + def send_msg(self, users, msg): + accounts, __, __ = self.get_accounts(users) + return self.client.send_text(accounts, msg) diff --git a/apps/settings/api/__init__.py b/apps/settings/api/__init__.py index 39e009ed5..d7cfa4cec 100644 --- a/apps/settings/api/__init__.py +++ b/apps/settings/api/__init__.py @@ -2,3 +2,4 @@ from .common import * from .ldap import * from .wecom import * from .dingtalk import * +from .feishu import * diff --git a/apps/settings/api/common.py b/apps/settings/api/common.py index 27e3eff54..5f0e6f89c 100644 --- a/apps/settings/api/common.py +++ b/apps/settings/api/common.py @@ -130,6 +130,7 @@ class PublicSettingApi(generics.RetrieveAPIView): }, "AUTH_WECOM": settings.AUTH_WECOM, "AUTH_DINGTALK": settings.AUTH_DINGTALK, + "AUTH_FEISHU": settings.AUTH_FEISHU, 'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED } } @@ -148,6 +149,7 @@ class SettingsApi(generics.RetrieveUpdateAPIView): 'email_content': serializers.EmailContentSettingSerializer, 'wecom': serializers.WeComSettingSerializer, 'dingtalk': serializers.DingTalkSettingSerializer, + 'feishu': serializers.FeiShuSettingSerializer, } def get_serializer_class(self): diff --git a/apps/settings/api/feishu.py b/apps/settings/api/feishu.py new file mode 100644 index 000000000..3e3d720b1 --- /dev/null +++ b/apps/settings/api/feishu.py @@ -0,0 +1,41 @@ +from rest_framework.views import Response +from rest_framework.generics import GenericAPIView +from rest_framework.exceptions import APIException +from rest_framework import status +from django.utils.translation import gettext_lazy as _ + +from settings.models import Setting +from common.permissions import IsSuperUser +from common.message.backends.feishu import FeiShu + +from .. import serializers + + +class FeiShuTestingAPI(GenericAPIView): + permission_classes = (IsSuperUser,) + serializer_class = serializers.FeiShuSettingSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + app_id = serializer.validated_data['FEISHU_APP_ID'] + app_secret = serializer.validated_data.get('FEISHU_APP_SECRET') + + if not app_secret: + secret = Setting.objects.filter(name='FEISHU_APP_SECRET').first() + if secret: + app_secret = secret.cleaned_value + + app_secret = app_secret or '' + + try: + feishu = FeiShu(app_id=app_id, app_secret=app_secret) + feishu.send_text(['test'], 'test') + return Response(status=status.HTTP_200_OK, data={'msg': _('Test success')}) + except APIException as e: + try: + error = e.detail['errmsg'] + except: + error = e.detail + return Response(status=status.HTTP_400_BAD_REQUEST, data={'error': error}) diff --git a/apps/settings/serializers/settings.py b/apps/settings/serializers/settings.py index 1039fd557..b9159c2cd 100644 --- a/apps/settings/serializers/settings.py +++ b/apps/settings/serializers/settings.py @@ -7,6 +7,7 @@ __all__ = [ 'BasicSettingSerializer', 'EmailSettingSerializer', 'EmailContentSettingSerializer', 'LDAPSettingSerializer', 'TerminalSettingSerializer', 'SecuritySettingSerializer', 'SettingsSerializer', 'WeComSettingSerializer', 'DingTalkSettingSerializer', + 'FeiShuSettingSerializer', ] @@ -218,6 +219,12 @@ class DingTalkSettingSerializer(serializers.Serializer): AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('Enable DingTalk Auth')) +class FeiShuSettingSerializer(serializers.Serializer): + FEISHU_APP_ID = serializers.CharField(max_length=256, required=True, label='App ID') + FEISHU_APP_SECRET = serializers.CharField(max_length=256, required=False, label='App Secret', write_only=True) + AUTH_FEISHU = serializers.BooleanField(default=False, label=_('Enable FeiShu Auth')) + + class SettingsSerializer( BasicSettingSerializer, EmailSettingSerializer, @@ -227,6 +234,7 @@ class SettingsSerializer( SecuritySettingSerializer, WeComSettingSerializer, DingTalkSettingSerializer, + FeiShuSettingSerializer, ): # encrypt_fields 现在使用 write_only 来判断了 diff --git a/apps/settings/urls/api_urls.py b/apps/settings/urls/api_urls.py index 86dfc6847..bd423611f 100644 --- a/apps/settings/urls/api_urls.py +++ b/apps/settings/urls/api_urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path('ldap/cache/refresh/', api.LDAPCacheRefreshAPI.as_view(), name='ldap-cache-refresh'), path('wecom/testing/', api.WeComTestingAPI.as_view(), name='wecom-testing'), path('dingtalk/testing/', api.DingTalkTestingAPI.as_view(), name='dingtalk-testing'), + path('feishu/testing/', api.FeiShuTestingAPI.as_view(), name='feishu-testing'), path('setting/', api.SettingsApi.as_view(), name='settings-setting'), path('public/', api.PublicSettingApi.as_view(), name='public-setting'), diff --git a/apps/static/img/login_feishu_logo.png b/apps/static/img/login_feishu_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..054f350f5c7047ac20c667e08298dfc1441e999a GIT binary patch literal 6640 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 literal 0 HcmV?d00001 diff --git a/apps/users/migrations/0036_user_feishu_id.py b/apps/users/migrations/0036_user_feishu_id.py new file mode 100644 index 000000000..3e5882c70 --- /dev/null +++ b/apps/users/migrations/0036_user_feishu_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.6 on 2021-08-06 02:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0035_auto_20210526_1100'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='feishu_id', + field=models.CharField(default=None, max_length=128, null=True, unique=True), + ), + ] diff --git a/apps/users/models/user.py b/apps/users/models/user.py index f921058fa..c6a689c7e 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -610,6 +610,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): ) wecom_id = models.CharField(null=True, default=None, unique=True, max_length=128) dingtalk_id = models.CharField(null=True, default=None, unique=True, max_length=128) + feishu_id = models.CharField(null=True, default=None, unique=True, max_length=128) def __str__(self): return '{0.name}({0.username})'.format(self) @@ -628,6 +629,10 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): def is_dingtalk_bound(self): return bool(self.dingtalk_id) + @property + def is_feishu_bound(self): + return bool(self.feishu_id) + def get_absolute_url(self): return reverse('users:user-detail', args=(self.id,)) diff --git a/apps/users/serializers/user.py b/apps/users/serializers/user.py index c718f29ec..e8760a3ee 100644 --- a/apps/users/serializers/user.py +++ b/apps/users/serializers/user.py @@ -54,7 +54,7 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): 'mfa_enabled', 'is_valid', 'is_expired', 'is_active', # 布尔字段 'date_expired', 'date_joined', 'last_login', # 日期字段 'created_by', 'comment', # 通用字段 - 'is_wecom_bound', 'is_dingtalk_bound', + 'is_wecom_bound', 'is_dingtalk_bound', 'is_feishu_bound', ] # 包含不太常用的字段,可以没有 fields_verbose = fields_small + [