diff --git a/apps/audits/signals_handler.py b/apps/audits/signals_handler.py index c99c52bc9..930a1f03d 100644 --- a/apps/audits/signals_handler.py +++ b/apps/audits/signals_handler.py @@ -57,6 +57,8 @@ class AuthBackendLabelMapping(LazyObject): backend_label_mapping[settings.AUTH_BACKEND_PUBKEY] = _('SSH Key') backend_label_mapping[settings.AUTH_BACKEND_MODEL] = _('Password') backend_label_mapping[settings.AUTH_BACKEND_SSO] = _('SSO') + backend_label_mapping[settings.AUTH_BACKEND_WECOM] = _('WeCom') + backend_label_mapping[settings.AUTH_BACKEND_DINGTALK] = _('DingTalk') return backend_label_mapping def _setup(self): diff --git a/apps/authentication/api/__init__.py b/apps/authentication/api/__init__.py index 12b83421f..6ef54b09b 100644 --- a/apps/authentication/api/__init__.py +++ b/apps/authentication/api/__init__.py @@ -7,3 +7,6 @@ from .mfa import * from .access_key import * from .login_confirm import * from .sso import * +from .wecom import * +from .dingtalk import * +from .password import * diff --git a/apps/authentication/api/dingtalk.py b/apps/authentication/api/dingtalk.py new file mode 100644 index 000000000..e4b2ea85b --- /dev/null +++ b/apps/authentication/api/dingtalk.py @@ -0,0 +1,35 @@ +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 DingTalkQRUnBindBase(APIView): + user: User + + def post(self, request: Request, **kwargs): + user = self.user + + if not user.dingtalk_id: + raise errors.DingTalkNotBound + + user.dingtalk_id = '' + user.save() + return Response() + + +class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase): + permission_classes = (IsAuthPasswdTimeValid,) + + +class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase): + user_id_url_kwarg = 'user_id' + permission_classes = (IsOrgAdmin,) diff --git a/apps/authentication/api/password.py b/apps/authentication/api/password.py new file mode 100644 index 000000000..af8b41358 --- /dev/null +++ b/apps/authentication/api/password.py @@ -0,0 +1,26 @@ +from rest_framework.generics import CreateAPIView +from rest_framework.response import Response + +from authentication.serializers import PasswordVerifySerializer +from common.permissions import IsValidUser +from authentication.mixins import authenticate +from authentication.errors import PasswdInvalid +from authentication.mixins import AuthMixin + + +class UserPasswordVerifyApi(AuthMixin, CreateAPIView): + permission_classes = (IsValidUser,) + serializer_class = PasswordVerifySerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + password = serializer.validated_data['password'] + user = self.request.user + + user = authenticate(request=request, username=user.username, password=password) + if not user: + raise PasswdInvalid + + self.set_passwd_verify_on_session(user) + return Response() diff --git a/apps/authentication/api/wecom.py b/apps/authentication/api/wecom.py new file mode 100644 index 000000000..1ab5ff725 --- /dev/null +++ b/apps/authentication/api/wecom.py @@ -0,0 +1,35 @@ +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 WeComQRUnBindBase(APIView): + user: User + + def post(self, request: Request, **kwargs): + user = self.user + + if not user.wecom_id: + raise errors.WeComNotBound + + user.wecom_id = '' + user.save() + return Response() + + +class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase): + permission_classes = (IsAuthPasswdTimeValid,) + + +class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase): + user_id_url_kwarg = 'user_id' + permission_classes = (IsOrgAdmin,) diff --git a/apps/authentication/backends/api.py b/apps/authentication/backends/api.py index 1fd315abb..63356eff6 100644 --- a/apps/authentication/backends/api.py +++ b/apps/authentication/backends/api.py @@ -205,3 +205,21 @@ class SSOAuthentication(ModelBackend): def authenticate(self, request, sso_token=None, **kwargs): pass + + +class WeComAuthentication(ModelBackend): + """ + 什么也不做呀😺 + """ + + def authenticate(self, request, **kwargs): + pass + + +class DingTalkAuthentication(ModelBackend): + """ + 什么也不做呀😺 + """ + + def authenticate(self, request, **kwargs): + pass diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 03368baa8..bcd83c97d 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -19,6 +19,7 @@ reason_user_inactive = 'user_inactive' reason_user_expired = 'user_expired' reason_backend_not_match = 'backend_not_match' reason_acl_not_allow = 'acl_not_allow' +only_local_users_are_allowed = 'only_local_users_are_allowed' reason_choices = { reason_password_failed: _('Username/password check failed'), @@ -32,6 +33,7 @@ reason_choices = { reason_user_expired: _("This account is expired"), reason_backend_not_match: _("Auth backend not match"), reason_acl_not_allow: _("ACL is not allowed"), + only_local_users_are_allowed: _("Only local users are allowed") } old_reason_choices = { '0': '-', @@ -291,3 +293,28 @@ class PasswordRequireResetError(JMSException): def __init__(self, url, *args, **kwargs): super().__init__(*args, **kwargs) self.url = url + + +class WeComCodeInvalid(JMSException): + default_code = 'wecom_code_invalid' + default_detail = 'Code invalid, can not get user info' + + +class WeComBindAlready(JMSException): + default_code = 'wecom_bind_already' + default_detail = 'WeCom already binded' + + +class WeComNotBound(JMSException): + default_code = 'wecom_not_bound' + default_detail = 'WeCom is not bound' + + +class DingTalkNotBound(JMSException): + default_code = 'dingtalk_not_bound' + default_detail = 'DingTalk is not bound' + + +class PasswdInvalid(JMSException): + default_code = 'passwd_invalid' + default_detail = _('Your password is invalid') diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 9b81d17fe..5eeceb7c3 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -5,6 +5,7 @@ from urllib.parse import urlencode from functools import partial import time +from django.core.cache import cache from django.conf import settings from django.contrib import auth from django.utils.translation import ugettext as _ @@ -12,7 +13,7 @@ from django.contrib.auth import ( BACKEND_SESSION_KEY, _get_backends, PermissionDenied, user_login_failed, _clean_credentials ) -from django.shortcuts import reverse +from django.shortcuts import reverse, redirect from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil from users.models import User @@ -82,6 +83,8 @@ class AuthMixin: request = None partial_credential_error = None + key_prefix_captcha = "_LOGIN_INVALID_{}" + def get_user_from_session(self): if self.request.session.is_empty(): raise errors.SessionEmptyError() @@ -110,11 +113,7 @@ class AuthMixin: ip = ip or get_request_ip(self.request) return ip - def check_is_block(self, raise_exception=True): - if hasattr(self.request, 'data'): - username = self.request.data.get("username") - else: - username = self.request.POST.get("username") + def _check_is_block(self, username, raise_exception=True): ip = self.get_request_ip() if LoginBlockUtil(username, ip).is_block(): logger.warn('Ip was blocked' + ': ' + username + ':' + ip) @@ -124,6 +123,13 @@ class AuthMixin: else: return exception + def check_is_block(self, raise_exception=True): + if hasattr(self.request, 'data'): + username = self.request.data.get("username") + else: + username = self.request.POST.get("username") + self._check_is_block(username, raise_exception) + def decrypt_passwd(self, raw_passwd): # 获取解密密钥,对密码进行解密 rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) @@ -140,6 +146,9 @@ class AuthMixin: def raise_credential_error(self, error): raise self.partial_credential_error(error=error) + def _set_partial_credential_error(self, username, ip, request): + self.partial_credential_error = partial(errors.CredentialError, username=username, ip=ip, request=request) + def get_auth_data(self, decrypt_passwd=False): request = self.request if hasattr(request, 'data'): @@ -151,7 +160,7 @@ class AuthMixin: username, password, challenge, public_key, auto_login = bulk_get(data, *items, default='') password = password + challenge.strip() ip = self.get_request_ip() - self.partial_credential_error = partial(errors.CredentialError, username=username, ip=ip, request=request) + self._set_partial_credential_error(username=username, ip=ip, request=request) if decrypt_passwd: password = self.decrypt_passwd(password) @@ -184,6 +193,21 @@ class AuthMixin: if not is_allowed: raise errors.LoginIPNotAllowed(username=user.username, request=self.request) + def set_login_failed_mark(self): + ip = self.get_request_ip() + cache.set(self.key_prefix_captcha.format(ip), 1, 3600) + + def set_passwd_verify_on_session(self, user: User): + self.request.session['user_id'] = str(user.id) + self.request.session['auth_password'] = 1 + self.request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS + + def check_is_need_captcha(self): + # 最近有登录失败时需要填写验证码 + ip = get_request_ip(self.request) + need = cache.get(self.key_prefix_captcha.format(ip)) + return need + def check_user_auth(self, decrypt_passwd=False): self.check_is_block() request = self.request @@ -204,6 +228,27 @@ class AuthMixin: request.session['auth_backend'] = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL) return user + def _check_is_local_user(self, user: User): + if user.source != User.Source.local: + raise self.raise_credential_error(error=errors.only_local_users_are_allowed) + + def check_oauth2_auth(self, user: User, auth_backend): + ip = self.get_request_ip() + request = self.request + + self._set_partial_credential_error(user.username, ip, request) + self._check_is_local_user(user) + self._check_is_block(user.username) + self._check_login_acl(user, ip) + + LoginBlockUtil(user.username, ip).clean_failed_count() + MFABlockUtils(user.username, ip).clean_failed_count() + + request.session['auth_password'] = 1 + request.session['user_id'] = str(user.id) + request.session['auth_backend'] = auth_backend + return user + @classmethod def generate_reset_password_url_with_flash_msg(cls, user, message): reset_passwd_url = reverse('authentication:reset-password') @@ -354,3 +399,10 @@ class AuthMixin: sender=self.__class__, username=username, request=self.request, reason=reason ) + + def redirect_to_guard_view(self): + guard_url = reverse('authentication:login-guard') + args = self.request.META.get('QUERY_STRING', '') + if args: + guard_url = "%s?%s" % (guard_url, args) + return redirect(guard_url) diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index 5f2bc231b..72b54e3ee 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -10,13 +10,14 @@ from applications.models import Application from users.serializers import UserProfileSerializer from assets.serializers import ProtocolsField from perms.serializers.asset.permission import ActionsField -from .models import AccessKey, LoginConfirmSetting, SSOToken +from .models import AccessKey, LoginConfirmSetting __all__ = [ 'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer', 'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer', - 'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', 'RDPFileSerializer' + 'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', 'RDPFileSerializer', + 'PasswordVerifySerializer', ] @@ -31,6 +32,10 @@ class OtpVerifySerializer(serializers.Serializer): code = serializers.CharField(max_length=6, min_length=6) +class PasswordVerifySerializer(serializers.Serializer): + password = serializers.CharField() + + class BearerTokenSerializer(serializers.Serializer): username = serializers.CharField(allow_null=True, required=False, write_only=True) password = serializers.CharField(write_only=True, allow_null=True, diff --git a/apps/authentication/templates/authentication/login.html b/apps/authentication/templates/authentication/login.html index 722ef5d16..0104d0e44 100644 --- a/apps/authentication/templates/authentication/login.html +++ b/apps/authentication/templates/authentication/login.html @@ -117,6 +117,15 @@ float: right; margin: 10px 10px 0 0; } + .more-login-item { + border-right: 1px dashed #dedede; + padding-left: 5px; + padding-right: 5px; + } + + .more-login-item:last-child { + border: none; + } @@ -182,10 +191,10 @@
- {% if AUTH_OPENID or AUTH_CAS %} + {% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK %}
- {% trans "More login options" %} + {% trans "More login options" %} {% if AUTH_OPENID %} {% endif %} + {% if AUTH_WECOM %} + + {% endif %} + {% if AUTH_DINGTALK %} + + {% endif %} +
{% else %}
diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index d9b302800..0849cc82a 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -14,10 +14,17 @@ router.register('connection-token', api.UserConnectionTokenViewSet, 'connection- urlpatterns = [ # path('token/', api.UserToken.as_view(), name='user-token'), + path('wecom/qr/unbind/', api.WeComQRUnBindForUserApi.as_view(), name='wecom-qr-unbind'), + path('wecom/qr/unbind//', api.WeComQRUnBindForAdminApi.as_view(), name='wecom-qr-unbind-for-admin'), + + 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('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'), path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'), + path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'), path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'), path('login-confirm-settings//', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update') ] diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index c4e4de00a..8e754340c 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -21,6 +21,22 @@ urlpatterns = [ path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'), path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-password'), + path('wecom/bind/success-flash-msg/', views.FlashWeComBindSucceedMsgView.as_view(), name='wecom-bind-success-flash-msg'), + path('wecom/bind/failed-flash-msg/', views.FlashWeComBindFailedMsgView.as_view(), name='wecom-bind-failed-flash-msg'), + path('wecom/bind/start/', views.WeComEnableStartView.as_view(), name='wecom-bind-start'), + path('wecom/qr/bind/', views.WeComQRBindView.as_view(), name='wecom-qr-bind'), + path('wecom/qr/login/', views.WeComQRLoginView.as_view(), name='wecom-qr-login'), + path('wecom/qr/bind//callback/', views.WeComQRBindCallbackView.as_view(), name='wecom-qr-bind-callback'), + path('wecom/qr/login/callback/', views.WeComQRLoginCallbackView.as_view(), name='wecom-qr-login-callback'), + + path('dingtalk/bind/success-flash-msg/', views.FlashDingTalkBindSucceedMsgView.as_view(), name='dingtalk-bind-success-flash-msg'), + path('dingtalk/bind/failed-flash-msg/', views.FlashDingTalkBindFailedMsgView.as_view(), name='dingtalk-bind-failed-flash-msg'), + path('dingtalk/bind/start/', views.DingTalkEnableStartView.as_view(), name='dingtalk-bind-start'), + path('dingtalk/qr/bind/', views.DingTalkQRBindView.as_view(), name='dingtalk-qr-bind'), + path('dingtalk/qr/login/', views.DingTalkQRLoginView.as_view(), name='dingtalk-qr-login'), + 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'), + # 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 5a1a40f7a..0467e321a 100644 --- a/apps/authentication/views/__init__.py +++ b/apps/authentication/views/__init__.py @@ -2,3 +2,5 @@ # from .login import * from .mfa import * +from .wecom import * +from .dingtalk import * diff --git a/apps/authentication/views/dingtalk.py b/apps/authentication/views/dingtalk.py new file mode 100644 index 000000000..521a93c26 --- /dev/null +++ b/apps/authentication/views/dingtalk.py @@ -0,0 +1,243 @@ +import urllib + +from django.http.response import HttpResponseRedirect +from django.utils.decorators import method_decorator +from django.utils.translation import ugettext 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 rest_framework.permissions import IsAuthenticated, AllowAny + +from users.views import UserVerifyPasswordView +from users.utils import is_auth_password_time_valid +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.message.backends.dingtalk import URL +from common.mixins.views import PermissionsMixin +from authentication import errors +from authentication.mixins import AuthMixin +from common.message.backends.dingtalk import DingTalk + +logger = get_logger(__file__) + + +DINGTALK_STATE_SESSION_KEY = '_dingtalk_state' + + +class DingTalkQRMixin(PermissionsMixin, View): + def verify_state(self): + state = self.request.GET.get('state') + session_state = self.request.session.get(DINGTALK_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[DINGTALK_STATE_SESSION_KEY] = state + + params = { + 'appid': settings.DINGTALK_APPKEY, + 'response_type': 'code', + 'scope': 'snsapi_login', + 'state': state, + 'redirect_uri': redirect_uri, + } + url = URL.QR_CONNECT + '?' + urllib.parse.urlencode(params) + return url + + def get_success_reponse(self, redirect_url, title, msg): + ok_flash_msg_url = reverse('authentication:dingtalk-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:dingtalk-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 = _('DingTalk is already bound') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + + +class DingTalkQRBindView(DingTalkQRMixin, 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:dingtalk-qr-bind-callback', kwargs={'user_id': user.id}, external=True) + redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url}) + + url = self.get_qr_url(redirect_uri) + return HttpResponseRedirect(url) + + +class DingTalkQRBindCallbackView(DingTalkQRMixin, View): + permission_classes = (IsAuthenticated,) + + def get(self, request: HttpRequest, user_id): + 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 = get_object_or_none(User, id=user_id) + if user is None: + logger.error(f'DingTalkQR bind callback error, user_id invalid: user_id={user_id}') + msg = _('Invalid user_id') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + + if user.dingtalk_id: + response = self.get_already_bound_response(redirect_url) + return response + + dingtalk = DingTalk( + appid=settings.DINGTALK_APPKEY, + appsecret=settings.DINGTALK_APPSECRET, + agentid=settings.DINGTALK_AGENTID + ) + userid = dingtalk.get_userid_by_code(code) + + if not userid: + msg = _('DingTalk query user failed') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + + user.dingtalk_id = userid + user.save() + + msg = _('Binding DingTalk successfully') + response = self.get_success_reponse(redirect_url, msg, msg) + return response + + +class DingTalkEnableStartView(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:dingtalk-qr-bind') + + success_url += '?' + urllib.parse.urlencode({ + 'redirect_url': redirect_url or referer + }) + + return success_url + + +class DingTalkQRLoginView(DingTalkQRMixin, View): + permission_classes = (AllowAny,) + + def get(self, request: HttpRequest): + redirect_url = request.GET.get('redirect_url') + + redirect_uri = reverse('authentication:dingtalk-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 DingTalkQRLoginCallbackView(AuthMixin, DingTalkQRMixin, 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) + + dingtalk = DingTalk( + appid=settings.DINGTALK_APPKEY, + appsecret=settings.DINGTALK_APPSECRET, + agentid=settings.DINGTALK_AGENTID + ) + userid = dingtalk.get_userid_by_code(code) + if not userid: + # 正常流程不会出这个错误,hack 行为 + msg = _('Failed to get user from DingTalk') + response = self.get_failed_reponse(login_url, title=msg, msg=msg) + return response + + user = get_object_or_none(User, dingtalk_id=userid) + if user is None: + title = _('DingTalk is not bound') + msg = _('Please login with a password and then bind the WoCom') + response = self.get_failed_reponse(login_url, title=title, msg=msg) + return response + + try: + self.check_oauth2_auth(user, settings.AUTH_BACKEND_DINGTALK) + 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 FlashDingTalkBindSucceedMsgView(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 DingTalk successfully'), + 'messages': msg or _('Binding DingTalk 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 FlashDingTalkBindFailedMsgView(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 DingTalk failed'), + 'messages': msg or _('Binding DingTalk 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 2412ef54a..083418940 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals import os import datetime -from django.core.cache import cache from django.contrib.auth import login as auth_login, logout as auth_logout from django.http import HttpResponse from django.shortcuts import reverse, redirect @@ -38,7 +37,6 @@ __all__ = [ @method_decorator(csrf_protect, name='dispatch') @method_decorator(never_cache, name='dispatch') class UserLoginView(mixins.AuthMixin, FormView): - key_prefix_captcha = "_LOGIN_INVALID_{}" redirect_field_name = 'next' template_name = 'authentication/login.html' @@ -90,10 +88,9 @@ class UserLoginView(mixins.AuthMixin, FormView): try: self.check_user_auth(decrypt_passwd=True) except errors.AuthFailedError as e: - e = self.check_is_block(raise_exception=False) or e form.add_error(None, e.msg) - ip = self.get_request_ip() - cache.set(self.key_prefix_captcha.format(ip), 1, 3600) + self.set_login_failed_mark() + form_cls = get_user_login_form_cls(captcha=True) new_form = form_cls(data=form.data) new_form._errors = form.errors @@ -105,16 +102,8 @@ class UserLoginView(mixins.AuthMixin, FormView): self.clear_rsa_key() return self.redirect_to_guard_view() - def redirect_to_guard_view(self): - guard_url = reverse('authentication:login-guard') - args = self.request.META.get('QUERY_STRING', '') - if args: - guard_url = "%s?%s" % (guard_url, args) - return redirect(guard_url) - def get_form_class(self): - ip = get_request_ip(self.request) - if cache.get(self.key_prefix_captcha.format(ip)): + if self.check_is_need_captcha(): return get_user_login_form_cls(captcha=True) else: return get_user_login_form_cls() @@ -142,6 +131,8 @@ class UserLoginView(mixins.AuthMixin, FormView): 'demo_mode': os.environ.get("DEMO_MODE"), 'AUTH_OPENID': settings.AUTH_OPENID, 'AUTH_CAS': settings.AUTH_CAS, + 'AUTH_WECOM': settings.AUTH_WECOM, + 'AUTH_DINGTALK': settings.AUTH_DINGTALK, 'rsa_public_key': rsa_public_key, 'forgot_password_url': forgot_password_url } diff --git a/apps/authentication/views/wecom.py b/apps/authentication/views/wecom.py new file mode 100644 index 000000000..5dc683f87 --- /dev/null +++ b/apps/authentication/views/wecom.py @@ -0,0 +1,241 @@ +import urllib + +from django.http.response import HttpResponseRedirect +from django.utils.decorators import method_decorator +from django.utils.translation import ugettext 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 rest_framework.permissions import IsAuthenticated, AllowAny + +from users.views import UserVerifyPasswordView +from users.utils import is_auth_password_time_valid +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.message.backends.wecom import URL +from common.message.backends.wecom import WeCom +from common.mixins.views import PermissionsMixin +from authentication import errors +from authentication.mixins import AuthMixin + +logger = get_logger(__file__) + + +WECOM_STATE_SESSION_KEY = '_wecom_state' + + +class WeComQRMixin(PermissionsMixin, View): + def verify_state(self): + state = self.request.GET.get('state') + session_state = self.request.session.get(WECOM_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[WECOM_STATE_SESSION_KEY] = state + + params = { + 'appid': settings.WECOM_CORPID, + 'agentid': settings.WECOM_AGENTID, + 'state': state, + 'redirect_uri': redirect_uri, + } + url = URL.QR_CONNECT + '?' + urllib.parse.urlencode(params) + return url + + def get_success_reponse(self, redirect_url, title, msg): + ok_flash_msg_url = reverse('authentication:wecom-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:wecom-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 = _('WeCom is already bound') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + + +class WeComQRBindView(WeComQRMixin, 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:wecom-qr-bind-callback', kwargs={'user_id': user.id}, external=True) + redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url}) + + url = self.get_qr_url(redirect_uri) + return HttpResponseRedirect(url) + + +class WeComQRBindCallbackView(WeComQRMixin, View): + permission_classes = (IsAuthenticated,) + + def get(self, request: HttpRequest, user_id): + 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 = get_object_or_none(User, id=user_id) + if user is None: + logger.error(f'WeComQR bind callback error, user_id invalid: user_id={user_id}') + msg = _('Invalid user_id') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + + if user.wecom_id: + response = self.get_already_bound_response(redirect_url) + return response + + wecom = WeCom( + corpid=settings.WECOM_CORPID, + corpsecret=settings.WECOM_CORPSECRET, + agentid=settings.WECOM_AGENTID + ) + wecom_userid, __ = wecom.get_user_id_by_code(code) + if not wecom_userid: + msg = _('WeCom query user failed') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + + user.wecom_id = wecom_userid + user.save() + + msg = _('Binding WeCom successfully') + response = self.get_success_reponse(redirect_url, msg, msg) + return response + + +class WeComEnableStartView(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:wecom-qr-bind') + + success_url += '?' + urllib.parse.urlencode({ + 'redirect_url': redirect_url or referer + }) + + return success_url + + +class WeComQRLoginView(WeComQRMixin, View): + permission_classes = (AllowAny,) + + def get(self, request: HttpRequest): + redirect_url = request.GET.get('redirect_url') + + redirect_uri = reverse('authentication:wecom-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 WeComQRLoginCallbackView(AuthMixin, WeComQRMixin, 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) + + wecom = WeCom( + corpid=settings.WECOM_CORPID, + corpsecret=settings.WECOM_CORPSECRET, + agentid=settings.WECOM_AGENTID + ) + wecom_userid, __ = wecom.get_user_id_by_code(code) + if not wecom_userid: + # 正常流程不会出这个错误,hack 行为 + msg = _('Failed to get user from WeCom') + response = self.get_failed_reponse(login_url, title=msg, msg=msg) + return response + + user = get_object_or_none(User, wecom_id=wecom_userid) + if user is None: + title = _('WeCom is not bound') + msg = _('Please login with a password and then bind the WoCom') + response = self.get_failed_reponse(login_url, title=title, msg=msg) + return response + + try: + self.check_oauth2_auth(user, settings.AUTH_BACKEND_WECOM) + 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 FlashWeComBindSucceedMsgView(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 WeCom successfully'), + 'messages': msg or _('Binding WeCom 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 FlashWeComBindFailedMsgView(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 WeCom failed'), + 'messages': msg or _('Binding WeCom failed'), + 'interval': 5, + 'redirect_url': request.GET.get('redirect_url'), + 'auto_redirect': True, + } + return self.render_to_response(context) diff --git a/apps/common/message/__init__.py b/apps/common/message/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/common/message/backends/__init__.py b/apps/common/message/backends/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/common/message/backends/dingtalk/__init__.py b/apps/common/message/backends/dingtalk/__init__.py new file mode 100644 index 000000000..0ca9d5dc5 --- /dev/null +++ b/apps/common/message/backends/dingtalk/__init__.py @@ -0,0 +1,168 @@ +import time +import hmac +import base64 + +from common.message.backends.utils import request +from common.message.backends.utils import digest +from common.message.backends.mixin import BaseRequest + + +def sign(secret, data): + + digest = hmac.HMAC( + key=secret.encode('utf8'), + msg=data.encode('utf8'), + digestmod=hmac._hashlib.sha256).digest() + signature = base64.standard_b64encode(digest).decode('utf8') + # signature = urllib.parse.quote(signature, safe='') + # signature = signature.replace('+', '%20').replace('*', '%2A').replace('~', '%7E').replace('/', '%2F') + return signature + + +class ErrorCode: + INVALID_TOKEN = 88 + + +class URL: + QR_CONNECT = 'https://oapi.dingtalk.com/connect/qrconnect' + GET_USER_INFO_BY_CODE = 'https://oapi.dingtalk.com/sns/getuserinfo_bycode' + GET_TOKEN = 'https://oapi.dingtalk.com/gettoken' + SEND_MESSAGE_BY_TEMPLATE = 'https://oapi.dingtalk.com/topapi/message/corpconversation/sendbytemplate' + SEND_MESSAGE = 'https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2' + GET_SEND_MSG_PROGRESS = 'https://oapi.dingtalk.com/topapi/message/corpconversation/getsendprogress' + GET_USERID_BY_UNIONID = 'https://oapi.dingtalk.com/topapi/user/getbyunionid' + + +class DingTalkRequests(BaseRequest): + invalid_token_errcode = ErrorCode.INVALID_TOKEN + + def __init__(self, appid, appsecret, agentid, timeout=None): + self._appid = appid + self._appsecret = appsecret + self._agentid = agentid + + super().__init__(timeout=timeout) + + def get_access_token_cache_key(self): + return digest(self._appid, self._appsecret) + + def request_access_token(self): + # https://developers.dingtalk.com/document/app/obtain-orgapp-token?spm=ding_open_doc.document.0.0.3a256573JEWqIL#topic-1936350 + params = {'appkey': self._appid, 'appsecret': self._appsecret} + data = self.raw_request('get', url=URL.GET_TOKEN, params=params) + + access_token = data['access_token'] + expires_in = data['expires_in'] + return access_token, expires_in + + @request + def get(self, url, params=None, + with_token=False, with_sign=False, + check_errcode_is_0=True, + **kwargs): + pass + + @request + def post(self, url, json=None, params=None, + with_token=False, with_sign=False, + check_errcode_is_0=True, + **kwargs): + pass + + def _add_sign(self, params: dict): + timestamp = str(int(time.time() * 1000)) + signature = sign(self._appsecret, timestamp) + accessKey = self._appid + + params['timestamp'] = timestamp + params['signature'] = signature + params['accessKey'] = accessKey + + def request(self, method, url, params=None, + 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) + + return data + + +class DingTalk: + def __init__(self, appid, appsecret, agentid, timeout=None): + self._appid = appid + self._appsecret = appsecret + self._agentid = agentid + + self._request = DingTalkRequests( + appid=appid, appsecret=appsecret, agentid=agentid, + timeout=timeout + ) + + def get_userinfo_bycode(self, code): + # https://developers.dingtalk.com/document/app/obtain-the-user-information-based-on-the-sns-temporary-authorization?spm=ding_open_doc.document.0.0.3a256573y8Y7yg#topic-1995619 + body = { + "tmp_auth_code": code + } + + data = self._request.post(URL.GET_USER_INFO_BY_CODE, json=body, with_sign=True) + return data['user_info'] + + def get_userid_by_code(self, code): + user_info = self.get_userinfo_bycode(code) + unionid = user_info['unionid'] + userid = self.get_userid_by_unionid(unionid) + return userid + + def get_userid_by_unionid(self, unionid): + body = { + 'unionid': unionid + } + data = self._request.post(URL.GET_USERID_BY_UNIONID, json=body, with_token=True) + userid = data['result']['userid'] + return userid + + def send_by_template(self, template_id, user_ids, dept_ids, data): + body = { + 'agent_id': self._agentid, + 'template_id': template_id, + 'userid_list': ','.join(user_ids), + 'dept_id_list': ','.join(dept_ids), + 'data': data + } + data = self._request.post(URL.SEND_MESSAGE_BY_TEMPLATE, json=body, with_token=True) + + def send_text(self, user_ids, msg): + body = { + 'agent_id': self._agentid, + 'userid_list': ','.join(user_ids), + # 'dept_id_list': '', + 'to_all_user': False, + 'msg': { + 'msgtype': 'text', + 'text': { + 'content': msg + } + } + } + data = self._request.post(URL.SEND_MESSAGE, json=body, with_token=True) + return data + + def get_send_msg_progress(self, task_id): + body = { + 'agent_id': self._agentid, + 'task_id': task_id + } + + data = self._request.post(URL.GET_SEND_MSG_PROGRESS, json=body, with_token=True) + return data diff --git a/apps/common/message/backends/exceptions.py b/apps/common/message/backends/exceptions.py new file mode 100644 index 000000000..f72e8694d --- /dev/null +++ b/apps/common/message/backends/exceptions.py @@ -0,0 +1,28 @@ +from django.utils.translation import gettext_lazy as _ + +from rest_framework.exceptions import APIException + + +class HTTPNot200(APIException): + default_code = 'http_not_200' + default_detail = 'HTTP status is not 200' + + +class ErrCodeNot0(APIException): + default_code = 'errcode_not_0' + default_detail = 'Error code is not 0' + + +class ResponseDataKeyError(APIException): + default_code = 'response_data_key_error' + default_detail = 'Response data key error' + + +class NetError(APIException): + default_code = 'net_error' + default_detail = _('Network error, please contact system administrator') + + +class AccessTokenError(APIException): + default_code = 'access_token_error' + default_detail = 'Access token error, check config' diff --git a/apps/common/message/backends/mixin.py b/apps/common/message/backends/mixin.py new file mode 100644 index 000000000..3beb60272 --- /dev/null +++ b/apps/common/message/backends/mixin.py @@ -0,0 +1,94 @@ +import requests +from requests import exceptions as req_exce +from rest_framework.exceptions import PermissionDenied +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 . import exceptions as exce + +logger = get_logger(__name__) + + +class RequestMixin: + def check_errcode_is_0(self, data: DictWrapper): + errcode = data['errcode'] + if errcode != 0: + # 如果代码写的对,配置没问题,这里不该出错,系统性错误,直接抛异常 + errmsg = data['errmsg'] + logger.error(f'Response 200 but errcode is not 0: ' + f'errcode={errcode} ' + f'errmsg={errmsg} ') + raise exce.ErrCodeNot0(detail=str(data.raw_data)) + + def check_http_is_200(self, response): + if response.status_code != 200: + # 正常情况下不会返回非 200 响应码 + logger.error(f'Response error: ' + f'status_code={response.status_code} ' + f'url={response.url}' + f'\ncontent={response.content}') + raise exce.HTTPNot200 + + +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): + raise NotImplementedError + + def get_access_token_cache_key(self): + raise NotImplementedError + + def is_token_invalid(self, data): + errcode = data['errcode'] + if errcode == self.invalid_token_errcode: + return True + return False + + @lazyproperty + def access_token_cache_key(self): + return self.get_access_token_cache_key() + + def init_access_token(self): + access_token = cache.get(self.access_token_cache_key) + if access_token: + self.access_token = access_token + return + self.refresh_access_token() + + 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) + + def raw_request(self, method, url, **kwargs): + set_default(kwargs, self._request_kwargs) + raw_data = '' + 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) + + 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) diff --git a/apps/common/message/backends/utils.py b/apps/common/message/backends/utils.py new file mode 100644 index 000000000..6c6f2b593 --- /dev/null +++ b/apps/common/message/backends/utils.py @@ -0,0 +1,78 @@ +import hashlib +import inspect +from inspect import Parameter + +from common.utils.common import get_logger +from common.message.backends import exceptions as exce + +logger = get_logger(__name__) + + +def digest(corpid, corpsecret): + md5 = hashlib.md5() + md5.update(corpid.encode()) + md5.update(corpsecret.encode()) + digest = md5.hexdigest() + return digest + + +def update_values(default: dict, others: dict): + for key in default.keys(): + if key in others: + default[key] = others[key] + + +def set_default(data: dict, default: dict): + for key in default.keys(): + if key not in data: + data[key] = default[key] + + +class DictWrapper: + def __init__(self, data:dict): + self.raw_data = data + + def __getitem__(self, item): + # 网络请求返回的数据,不能完全信任,所以字典操作包在异常里 + try: + return self.raw_data[item] + except KeyError as e: + msg = f'Response 200 but get field from json error: error={e} data={self.raw_data}' + logger.error(msg) + raise exce.ResponseDataKeyError(detail=msg) + + def __getattr__(self, item): + return getattr(self.raw_data, item) + + def __contains__(self, item): + return item in self.raw_data + + def __str__(self): + return str(self.raw_data) + + def __repr__(self): + return str(self.raw_data) + + +def request(func): + def inner(*args, **kwargs): + signature = inspect.signature(func) + bound_args = signature.bind(*args, **kwargs) + bound_args.apply_defaults() + + arguments = bound_args.arguments + self = arguments['self'] + request_method = func.__name__ + + parameters = {} + for k, v in signature.parameters.items(): + if k == 'self': + continue + if v.kind is Parameter.VAR_KEYWORD: + parameters.update(arguments[k]) + continue + parameters[k] = arguments[k] + + response = self.request(request_method, **parameters) + return response + return inner diff --git a/apps/common/message/backends/wecom/__init__.py b/apps/common/message/backends/wecom/__init__.py new file mode 100644 index 000000000..257da47a0 --- /dev/null +++ b/apps/common/message/backends/wecom/__init__.py @@ -0,0 +1,194 @@ +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__) + + +class WeComError(APIException): + default_code = 'wecom_error' + default_detail = _('WeCom error, please contact system administrator') + + +class URL: + GET_TOKEN = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken' + SEND_MESSAGE = 'https://qyapi.weixin.qq.com/cgi-bin/message/send' + QR_CONNECT = 'https://open.work.weixin.qq.com/wwopen/sso/qrConnect' + + # https://open.work.weixin.qq.com/api/doc/90000/90135/91437 + GET_USER_ID_BY_CODE = 'https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo' + GET_USER_DETAIL = 'https://qyapi.weixin.qq.com/cgi-bin/user/get' + + +class ErrorCode: + # https://open.work.weixin.qq.com/api/doc/90000/90139/90313#%E9%94%99%E8%AF%AF%E7%A0%81%EF%BC%9A81013 + RECIPIENTS_INVALID = 81013 # UserID、部门ID、标签ID全部非法或无权限。 + + # https://open.work.weixin.qq.com/api/doc/90000/90135/91437 + INVALID_CODE = 40029 + + INVALID_TOKEN = 40014 # 无效的 access_token + + +class WeComRequests(BaseRequest): + """ + 处理系统级错误,抛出 API 异常,直接生成 HTTP 响应,业务代码无需关心这些错误 + - 确保 status_code == 200 + - 确保 access_token 无效时重试 + """ + invalid_token_errcode = ErrorCode.INVALID_TOKEN + + def __init__(self, corpid, corpsecret, agentid, timeout=None): + self._corpid = corpid + self._corpsecret = corpsecret + self._agentid = agentid + + super().__init__(timeout=timeout) + + def get_access_token_cache_key(self): + return digest(self._corpid, self._corpsecret) + + def request_access_token(self): + params = {'corpid': self._corpid, 'corpsecret': self._corpsecret} + data = self.raw_request('get', url=URL.GET_TOKEN, params=params) + + access_token = data['access_token'] + 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): + 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 + + +class WeCom(RequestMixin): + """ + 非业务数据导致的错误直接抛异常,说明是系统配置错误,业务代码不用理会 + """ + + def __init__(self, corpid, corpsecret, agentid, timeout=None): + self._corpid = corpid + self._corpsecret = corpsecret + self._agentid = agentid + + self._requests = WeComRequests( + corpid=corpid, + corpsecret=corpsecret, + agentid=agentid, + timeout=timeout + ) + + def send_text(self, users: Iterable, msg: AnyStr, **kwargs): + """ + https://open.work.weixin.qq.com/api/doc/90000/90135/90236 + + 对于业务代码,只需要关心由 用户id 或 消息不对 导致的错误,其他错误不予理会 + """ + users = tuple(users) + + extra_params = { + "safe": 0, + "enable_id_trans": 0, + "enable_duplicate_check": 0, + "duplicate_check_interval": 1800 + } + update_values(extra_params, kwargs) + + body = { + "touser": '|'.join(users), + "msgtype": "text", + "agentid": self._agentid, + "text": { + "content": msg + }, + **extra_params + } + data = self._requests.post(URL.SEND_MESSAGE, json=body, check_errcode_is_0=False) + + errcode = data['errcode'] + if errcode == ErrorCode.RECIPIENTS_INVALID: + # 全部接收人无权限或不存在 + return users + self.check_errcode_is_0(data) + + invaliduser = data['invaliduser'] + if not invaliduser: + return () + + if isinstance(invaliduser, str): + logger.error(f'WeCom send text 200, but invaliduser is not str: invaliduser={invaliduser}') + raise WeComError + + invalid_users = invaliduser.split('|') + return invalid_users + + def get_user_id_by_code(self, code): + # # https://open.work.weixin.qq.com/api/doc/90000/90135/91437 + + params = { + 'code': code, + } + data = self._requests.get(URL.GET_USER_ID_BY_CODE, params=params, check_errcode_is_0=False) + + errcode = data['errcode'] + if errcode == ErrorCode.INVALID_CODE: + logger.warn(f'WeCom get_user_id_by_code invalid code: code={code}') + return None, None + + self.check_errcode_is_0(data) + + USER_ID = 'UserId' + OPEN_ID = 'OpenId' + + if USER_ID in data: + return data[USER_ID], USER_ID + elif OPEN_ID in data: + return data[OPEN_ID], OPEN_ID + else: + logger.error(f'WeCom response 200 but get field from json error: fields=UserId|OpenId') + raise WeComError + + def get_user_detail(self, id): + # https://open.work.weixin.qq.com/api/doc/90000/90135/90196 + + params = { + 'userid': id, + } + + data = self._requests.get(URL.GET_USER_DETAIL, params) + return data diff --git a/apps/common/mixins/api.py b/apps/common/mixins/api.py index ea629bb84..a0d5875c6 100644 --- a/apps/common/mixins/api.py +++ b/apps/common/mixins/api.py @@ -6,10 +6,12 @@ from threading import Thread from collections import defaultdict from itertools import chain +from django.conf import settings from django.db.models.signals import m2m_changed from django.core.cache import cache from django.http import JsonResponse from django.utils.translation import ugettext as _ +from django.contrib.auth import get_user_model from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.decorators import action @@ -25,6 +27,9 @@ __all__ = [ ] +UserModel = get_user_model() + + class JSONResponseMixin(object): """JSON mixin""" @staticmethod @@ -332,3 +337,21 @@ class AllowBulkDestoryMixin: """ query = str(filtered.query) return '`id` IN (' in query or '`id` =' in query + + +class RoleAdminMixin: + kwargs: dict + user_id_url_kwarg = 'pk' + + @lazyproperty + def user(self): + user_id = self.kwargs.get(self.user_id_url_kwarg) + return UserModel.objects.get(id=user_id) + + +class RoleUserMixin: + request: Request + + @lazyproperty + def user(self): + return self.request.user diff --git a/apps/common/request_log.py b/apps/common/request_log.py new file mode 100644 index 000000000..c35e6b84a --- /dev/null +++ b/apps/common/request_log.py @@ -0,0 +1,15 @@ +from django.http.request import HttpRequest +from django.http.response import HttpResponse + +from orgs.utils import current_org + + +class RequestLogMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request: HttpRequest): + print(f'Request {request.method} --> ', request.get_raw_uri()) + response: HttpResponse = self.get_response(request) + print(f'Response {current_org.name} {request.method} {response.status_code} --> ', request.get_raw_uri()) + return response diff --git a/apps/common/utils/random.py b/apps/common/utils/random.py index 055966947..a9ef0421f 100644 --- a/apps/common/utils/random.py +++ b/apps/common/utils/random.py @@ -4,7 +4,6 @@ import struct import random import socket import string -import secrets string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_{}~' diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index b51c7dfba..57c754942 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -216,6 +216,16 @@ class Config(dict): 'AUTH_SSO': False, 'AUTH_SSO_AUTHKEY_TTL': 60 * 15, + 'AUTH_WECOM': False, + 'WECOM_CORPID': '', + 'WECOM_AGENTID': '', + 'WECOM_CORPSECRET': '', + + 'AUTH_DINGTALK': False, + 'DINGTALK_AGENTID': '', + 'DINGTALK_APPKEY': '', + 'DINGTALK_APPSECRET': '', + '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 ad8c8c90f..fbd7016d3 100644 --- a/apps/jumpserver/context_processor.py +++ b/apps/jumpserver/context_processor.py @@ -14,6 +14,8 @@ def jumpserver_processor(request): 'LOGIN_IMAGE_URL': static('img/login_image.png'), 'FAVICON_URL': static('img/facio.ico'), 'LOGIN_CAS_LOGO_URL': static('img/login_cas_logo.png'), + 'LOGIN_WECOM_LOGO_URL': static('img/login_wecom_log.png'), + 'LOGIN_DINGTALK_LOGO_URL': static('img/login_dingtalk_log.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 579a0e66f..4fb77cb47 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -101,6 +101,19 @@ CAS_CHECK_NEXT = lambda _next_page: True AUTH_SSO = CONFIG.AUTH_SSO AUTH_SSO_AUTHKEY_TTL = CONFIG.AUTH_SSO_AUTHKEY_TTL +# WECOM Auth +AUTH_WECOM = CONFIG.AUTH_WECOM +WECOM_CORPID = CONFIG.WECOM_CORPID +WECOM_AGENTID = CONFIG.WECOM_AGENTID +WECOM_CORPSECRET = CONFIG.WECOM_CORPSECRET + +# DingDing auth +AUTH_DINGTALK = CONFIG.AUTH_DINGTALK +DINGTALK_AGENTID = CONFIG.DINGTALK_AGENTID +DINGTALK_APPKEY = CONFIG.DINGTALK_APPKEY +DINGTALK_APPSECRET = CONFIG.DINGTALK_APPSECRET + + # Other setting TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION LOGIN_CONFIRM_ENABLE = CONFIG.LOGIN_CONFIRM_ENABLE @@ -115,6 +128,8 @@ AUTH_BACKEND_OIDC_CODE = 'jms_oidc_rp.backends.OIDCAuthCodeBackend' AUTH_BACKEND_RADIUS = 'authentication.backends.radius.RadiusBackend' 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' AUTHENTICATION_BACKENDS = [AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY] @@ -128,6 +143,10 @@ if AUTH_RADIUS: AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_RADIUS) if AUTH_SSO: AUTHENTICATION_BACKENDS.append(AUTH_BACKEND_SSO) +if AUTH_WECOM: + AUTHENTICATION_BACKENDS.append(AUTH_BACKEND_WECOM) +if AUTH_DINGTALK: + AUTHENTICATION_BACKENDS.append(AUTH_BACKEND_DINGTALK) ONLY_ALLOW_EXIST_USER_AUTH = CONFIG.ONLY_ALLOW_EXIST_USER_AUTH diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index ca9870043..687b7f2ae 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -23,7 +23,7 @@ api_v1 = [ path('applications/', include('applications.urls.api_urls', namespace='api-applications')), path('tickets/', include('tickets.urls.api_urls', namespace='api-tickets')), path('acls/', include('acls.urls.api_urls', namespace='api-acls')), - path('prometheus/metrics/', api.PrometheusMetricsApi.as_view()) + path('prometheus/metrics/', api.PrometheusMetricsApi.as_view()), ] app_view_patterns = [ diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 2bc2e78e2..c7bd94308 100644 Binary files a/apps/locale/zh/LC_MESSAGES/django.mo and b/apps/locale/zh/LC_MESSAGES/django.mo differ diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index ee97bf8fd..4f063d091 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-04-29 16:20+0800\n" +"POT-Creation-Date: 2021-05-12 12:00+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -93,13 +93,13 @@ 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/serializers.py:84 authentication/models.py:44 +#: audits/serializers.py:94 authentication/models.py:44 #: authentication/models.py:97 orgs/models.py:18 orgs/models.py:418 #: 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:184 -#: users/models/user.py:733 users/models/user.py:759 +#: users/models/user.py:743 users/models/user.py:769 #: users/serializers/group.py:20 #: users/templates/users/user_asset_permission.html:38 #: users/templates/users/user_asset_permission.html:64 @@ -120,9 +120,9 @@ msgstr "系统用户" #: acls/models/login_asset_acl.py:22 #: applications/serializers/attrs/application_category/remote_app.py:33 #: assets/models/asset.py:355 assets/models/authbook.py:26 -#: assets/models/gathered_user.py:14 assets/serializers/admin_user.py:30 -#: assets/serializers/asset_user.py:47 assets/serializers/asset_user.py:84 -#: assets/serializers/system_user.py:192 audits/models.py:38 +#: assets/models/gathered_user.py:14 assets/serializers/admin_user.py:34 +#: assets/serializers/asset_user.py:48 assets/serializers/asset_user.py:89 +#: assets/serializers/system_user.py:195 audits/models.py:38 #: perms/models/asset_permission.py:99 templates/index.html:82 #: terminal/backends/command/models.py:19 #: terminal/backends/command/serializers.py:13 terminal/models/session.py:40 @@ -158,13 +158,13 @@ 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:183 assets/models/domain.py:52 -#: assets/serializers/asset_user.py:46 settings/serializers/settings.py:112 +#: assets/serializers/asset_user.py:47 settings/serializers/settings.py:112 #: users/templates/users/_granted_assets.html:26 #: users/templates/users/user_asset_permission.html:156 msgid "IP" msgstr "IP" -#: acls/serializers/login_acl.py:50 +#: acls/serializers/login_acl.py:55 msgid "The user `{}` is not in the current organization: `{}`" msgstr "用户 `{}` 不在当前组织: `{}`" @@ -198,7 +198,7 @@ msgstr "" "10.1.1.1-10.1.1.20, 2001:db8:2de::e13, 2001:db8:1a:1110::/64 (支持网域)" #: acls/serializers/login_asset_acl.py:35 assets/models/asset.py:184 -#: assets/serializers/asset_user.py:45 assets/serializers/gathered_user.py:20 +#: assets/serializers/asset_user.py:46 assets/serializers/gathered_user.py:23 #: settings/serializers/settings.py:111 #: users/templates/users/_granted_assets.html:25 #: users/templates/users/user_asset_permission.html:157 @@ -213,7 +213,7 @@ msgstr "格式为逗号分隔的字符串, * 表示匹配所有. 可选的协议 #: acls/serializers/login_asset_acl.py:55 assets/models/asset.py:187 #: assets/models/domain.py:54 assets/models/user.py:123 -#: terminal/serializers/session.py:29 terminal/serializers/storage.py:69 +#: terminal/serializers/session.py:32 terminal/serializers/storage.py:69 msgid "Protocol" msgstr "协议" @@ -221,12 +221,12 @@ msgstr "协议" msgid "Unsupported protocols: {}" msgstr "不支持的协议: {}" -#: acls/serializers/login_asset_acl.py:94 -#: tickets/serializers/ticket/ticket.py:109 +#: acls/serializers/login_asset_acl.py:98 +#: tickets/serializers/ticket/ticket.py:111 msgid "The organization `{}` does not exist" msgstr "组织 `{}` 不存在" -#: acls/serializers/login_asset_acl.py:99 +#: acls/serializers/login_asset_acl.py:103 msgid "None of the reviewers belong to Organization `{}`" msgstr "所有复核人都不属于组织 `{}`" @@ -315,9 +315,9 @@ msgstr "目标URL" #: applications/serializers/attrs/application_type/custom.py:25 #: applications/serializers/attrs/application_type/mysql_workbench.py:34 #: applications/serializers/attrs/application_type/vmware_client.py:30 -#: assets/models/base.py:252 assets/serializers/asset_user.py:71 +#: assets/models/base.py:252 assets/serializers/asset_user.py:76 #: audits/signals_handler.py:58 authentication/forms.py:22 -#: authentication/templates/authentication/login.html:155 +#: authentication/templates/authentication/login.html:164 #: settings/serializers/settings.py:93 users/forms/profile.py:21 #: users/templates/users/user_otp_check_password.html:13 #: users/templates/users/user_password_update.html:43 @@ -354,10 +354,8 @@ msgid "You can't delete the root node ({})" msgstr "不能删除根节点 ({})" #: assets/api/node.py:75 -#, fuzzy -#| msgid "Deletion failed and the node contains children or assets" msgid "Deletion failed and the node contains assets" -msgstr "删除失败,节点包含子节点或资产" +msgstr "删除失败,节点包含资产" #: assets/backends/db.py:244 msgid "Could not remove asset admin user" @@ -499,7 +497,7 @@ msgstr "创建者" #: 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:25 #: orgs/models.py:420 perms/models/base.py:56 users/models/group.py:18 -#: users/models/user.py:760 xpack/plugins/cloud/models.py:107 +#: users/models/user.py:770 xpack/plugins/cloud/models.py:107 msgid "Date created" msgstr "创建日期" @@ -571,7 +569,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:745 +#: users/models/user.py:755 msgid "System" msgstr "系统" @@ -676,7 +674,7 @@ msgstr "全称" msgid "Parent key" msgstr "ssh私钥" -#: assets/models/node.py:559 assets/serializers/system_user.py:191 +#: assets/models/node.py:559 assets/serializers/system_user.py:194 #: users/templates/users/user_asset_permission.html:41 #: users/templates/users/user_asset_permission.html:73 #: users/templates/users/user_asset_permission.html:158 @@ -789,7 +787,7 @@ msgstr "网域名称" msgid "Admin user name" msgstr "管理用户名称" -#: assets/serializers/asset.py:71 perms/serializers/asset/permission.py:47 +#: assets/serializers/asset.py:71 perms/serializers/asset/permission.py:49 msgid "Nodes name" msgstr "节点名称" @@ -805,22 +803,22 @@ msgstr "组织名称" msgid "Connectivity" msgstr "连接" -#: assets/serializers/asset_user.py:44 +#: assets/serializers/asset_user.py:45 #: authentication/templates/authentication/_access_key_modal.html:30 #: users/serializers/group.py:37 msgid "ID" msgstr "ID" -#: assets/serializers/asset_user.py:48 +#: assets/serializers/asset_user.py:49 msgid "Backend" msgstr "后端" -#: assets/serializers/asset_user.py:75 users/forms/profile.py:160 +#: assets/serializers/asset_user.py:80 users/forms/profile.py:160 #: users/models/user.py:585 users/templates/users/user_password_update.html:48 msgid "Public key" msgstr "SSH公钥" -#: assets/serializers/asset_user.py:79 users/models/user.py:582 +#: assets/serializers/asset_user.py:84 users/models/user.py:582 msgid "Private key" msgstr "ssh私钥" @@ -840,9 +838,9 @@ msgstr "应用数量" msgid "Gateways count" msgstr "网关数量" -#: assets/serializers/label.py:13 assets/serializers/system_user.py:45 -#: assets/serializers/system_user.py:166 -#: perms/serializers/asset/permission.py:72 +#: assets/serializers/label.py:13 assets/serializers/system_user.py:47 +#: assets/serializers/system_user.py:169 +#: perms/serializers/asset/permission.py:74 msgid "Assets amount" msgstr "资产数量" @@ -863,33 +861,33 @@ msgstr "不能包含: /" msgid "The same level node name cannot be the same" msgstr "同级别节点名字不能重复" -#: assets/serializers/system_user.py:44 assets/serializers/system_user.py:165 -#: perms/serializers/asset/permission.py:73 +#: assets/serializers/system_user.py:46 assets/serializers/system_user.py:168 +#: perms/serializers/asset/permission.py:75 msgid "Nodes amount" msgstr "节点数量" -#: assets/serializers/system_user.py:46 assets/serializers/system_user.py:167 -#: assets/serializers/system_user.py:193 +#: assets/serializers/system_user.py:48 assets/serializers/system_user.py:170 +#: assets/serializers/system_user.py:196 msgid "Login mode display" msgstr "登录模式(显示名称)" -#: assets/serializers/system_user.py:48 assets/serializers/system_user.py:169 +#: assets/serializers/system_user.py:50 assets/serializers/system_user.py:172 msgid "Ad domain" msgstr "Ad 网域" -#: assets/serializers/system_user.py:87 +#: assets/serializers/system_user.py:89 msgid "Username same with user with protocol {} only allow 1" msgstr "用户名和用户相同的一种协议只允许存在一个" -#: assets/serializers/system_user.py:100 +#: assets/serializers/system_user.py:102 msgid "* Automatic login mode must fill in the username." msgstr "自动登录模式,必须填写用户名" -#: assets/serializers/system_user.py:108 +#: assets/serializers/system_user.py:110 msgid "Path should starts with /" msgstr "路径应该以 / 开头" -#: assets/serializers/system_user.py:119 +#: assets/serializers/system_user.py:121 msgid "Password or private key required" msgstr "密码或密钥密码需要一个" @@ -1122,7 +1120,7 @@ msgstr "登录IP" msgid "Login city" msgstr "登录城市" -#: audits/models.py:104 audits/serializers.py:38 +#: audits/models.py:104 audits/serializers.py:45 msgid "User agent" msgstr "用户代理" @@ -1156,45 +1154,45 @@ msgstr "认证方式" msgid "Operate for display" msgstr "操作(显示名称)" -#: audits/serializers.py:26 +#: audits/serializers.py:30 msgid "Type for display" msgstr "类型(显示名称)" -#: audits/serializers.py:27 +#: audits/serializers.py:31 msgid "Status for display" msgstr "状态(显示名称)" -#: audits/serializers.py:28 +#: audits/serializers.py:32 msgid "MFA for display" msgstr "多因子认证状态(显示名称)" -#: audits/serializers.py:66 audits/serializers.py:81 ops/models/adhoc.py:247 -#: terminal/serializers/session.py:34 +#: audits/serializers.py:76 audits/serializers.py:91 ops/models/adhoc.py:247 +#: terminal/serializers/session.py:37 msgid "Is success" msgstr "是否成功" -#: audits/serializers.py:68 +#: audits/serializers.py:78 msgid "Hosts for display" msgstr "主机 (显示名称)" -#: audits/serializers.py:80 ops/models/command.py:26 +#: audits/serializers.py:90 ops/models/command.py:26 #: xpack/plugins/cloud/models.py:155 msgid "Result" msgstr "结果" -#: audits/serializers.py:82 terminal/serializers/storage.py:178 +#: audits/serializers.py:92 terminal/serializers/storage.py:178 msgid "Hosts" msgstr "主机" -#: audits/serializers.py:83 +#: audits/serializers.py:93 msgid "Run as" msgstr "运行用户" -#: audits/serializers.py:85 +#: audits/serializers.py:95 msgid "Run as for display" msgstr "运行用户(显示名称)" -#: audits/serializers.py:86 +#: audits/serializers.py:96 msgid "User for display" msgstr "用户(显示名称)" @@ -1206,6 +1204,12 @@ msgstr "SSH 密钥" msgid "SSO" msgstr "" +#: audits/signals_handler.py:60 +#: authentication/templates/authentication/login.html:210 +#: notifications/models.py:16 +msgid "WeCom" +msgstr "企业微信" + #: authentication/api/mfa.py:60 msgid "Code is invalid" msgstr "Code无效" @@ -1261,55 +1265,59 @@ msgstr "" msgid "Invalid token or cache refreshed." msgstr "" -#: authentication/errors.py:24 +#: authentication/errors.py:25 msgid "Username/password check failed" msgstr "用户名/密码 校验失败" -#: authentication/errors.py:25 +#: authentication/errors.py:26 msgid "Password decrypt failed" msgstr "密码解密失败" -#: authentication/errors.py:26 +#: authentication/errors.py:27 msgid "MFA failed" msgstr "多因子认证失败" -#: authentication/errors.py:27 +#: authentication/errors.py:28 msgid "MFA unset" msgstr "多因子认证没有设定" -#: authentication/errors.py:28 +#: authentication/errors.py:29 msgid "Username does not exist" msgstr "用户名不存在" -#: authentication/errors.py:29 +#: authentication/errors.py:30 msgid "Password expired" msgstr "密码已过期" -#: authentication/errors.py:30 +#: authentication/errors.py:31 msgid "Disabled or expired" msgstr "禁用或失效" -#: authentication/errors.py:31 +#: authentication/errors.py:32 msgid "This account is inactive." msgstr "此账户已禁用" -#: authentication/errors.py:32 +#: authentication/errors.py:33 msgid "This account is expired" msgstr "此账户已过期" -#: authentication/errors.py:33 +#: authentication/errors.py:34 msgid "Auth backend not match" msgstr "没有匹配到认证后端" -#: authentication/errors.py:34 +#: authentication/errors.py:35 msgid "ACL is not allowed" msgstr "ACL 不被允许" -#: authentication/errors.py:44 +#: authentication/errors.py:36 +msgid "Wecom login only for local user" +msgstr "" + +#: authentication/errors.py:46 msgid "No session found, check your cookie" msgstr "会话已变更,刷新页面" -#: authentication/errors.py:46 +#: authentication/errors.py:48 #, python-brace-format msgid "" "The username or password you entered is incorrect, please enter it again. " @@ -1319,13 +1327,13 @@ msgstr "" "您输入的用户名或密码不正确,请重新输入。 您还可以尝试 {times_try} 次(账号将" "被临时 锁定 {block_time} 分钟)" -#: authentication/errors.py:52 authentication/errors.py:56 +#: authentication/errors.py:54 authentication/errors.py:58 msgid "" "The account has been locked (please contact admin to unlock it or try again " "after {} minutes)" msgstr "账号已被锁定(请联系管理员解锁 或 {}分钟后重试)" -#: authentication/errors.py:60 +#: authentication/errors.py:62 #, python-brace-format msgid "" "MFA code invalid, or ntp sync server time, You can also try {times_try} " @@ -1334,46 +1342,50 @@ msgstr "" "MFA验证码不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" "临时 锁定 {block_time} 分钟)" -#: authentication/errors.py:65 +#: authentication/errors.py:67 msgid "MFA required" msgstr "需要多因子认证" -#: authentication/errors.py:66 +#: authentication/errors.py:68 msgid "MFA not set, please set it first" msgstr "多因子认证没有设置,请先完成设置" -#: authentication/errors.py:67 +#: authentication/errors.py:69 msgid "Login confirm required" msgstr "需要登录复核" -#: authentication/errors.py:68 +#: authentication/errors.py:70 msgid "Wait login confirm ticket for accept" msgstr "等待登录复核处理" -#: authentication/errors.py:69 +#: authentication/errors.py:71 msgid "Login confirm ticket was {}" msgstr "登录复核 {}" -#: authentication/errors.py:233 +#: authentication/errors.py:235 msgid "IP is not allowed" msgstr "来源 IP 不被允许登录" -#: authentication/errors.py:266 +#: authentication/errors.py:268 msgid "SSO auth closed" msgstr "SSO 认证关闭了" -#: authentication/errors.py:271 authentication/views/login.py:267 +#: authentication/errors.py:273 authentication/views/login.py:227 msgid "Your password is too simple, please change it for security" msgstr "你的密码过于简单,为了安全,请修改" -#: authentication/errors.py:280 authentication/views/login.py:282 +#: authentication/errors.py:282 authentication/views/login.py:242 msgid "You should to change your password before login" msgstr "登录完成前,请先修改密码" -#: authentication/errors.py:289 authentication/views/login.py:298 +#: authentication/errors.py:291 authentication/views/login.py:258 msgid "Your password has expired, please reset before logging in" msgstr "您的密码已过期,先修改再登录" +#: authentication/errors.py:320 +msgid "Your password is invalid" +msgstr "您的密码无效" + #: authentication/forms.py:26 msgid "{} days auto login" msgstr "{} 天内自动登录" @@ -1455,9 +1467,8 @@ msgid "Need MFA for view auth" msgstr "需要多因子认证来查看账号信息" #: authentication/templates/authentication/_mfa_confirm_modal.html:20 -#: authentication/views/login.py:286 authentication/views/login.py:302 -#: authentication/views/login.py:318 templates/_modal.html:23 -#: users/templates/users/user_password_verify.html:20 +#: authentication/views/login.py:246 authentication/views/login.py:262 +#: templates/_modal.html:23 users/templates/users/user_password_verify.html:20 msgid "Confirm" msgstr "确认" @@ -1465,33 +1476,38 @@ msgstr "确认" msgid "Code error" msgstr "代码错误" -#: authentication/templates/authentication/login.html:148 +#: authentication/templates/authentication/login.html:157 msgid "Welcome back, please enter username and password to login" msgstr "欢迎回来,请输入用户名和密码登录" -#: authentication/templates/authentication/login.html:174 +#: authentication/templates/authentication/login.html:183 #: users/templates/users/forgot_password.html:15 #: users/templates/users/forgot_password.html:16 msgid "Forgot password" msgstr "忘记密码" -#: authentication/templates/authentication/login.html:181 +#: authentication/templates/authentication/login.html:190 #: templates/_header_bar.html:83 msgid "Login" msgstr "登录" -#: authentication/templates/authentication/login.html:188 +#: authentication/templates/authentication/login.html:197 msgid "More login options" msgstr "更多登录方式" -#: authentication/templates/authentication/login.html:191 +#: authentication/templates/authentication/login.html:200 msgid "OpenID" msgstr "OpenID" -#: authentication/templates/authentication/login.html:196 +#: authentication/templates/authentication/login.html:205 msgid "CAS" msgstr "" +#: authentication/templates/authentication/login.html:215 +#: notifications/models.py:18 +msgid "DingTalk" +msgstr "钉钉" + #: authentication/templates/authentication/login_otp.html:17 msgid "One-time password" msgstr "一次性密码" @@ -1522,6 +1538,7 @@ msgid "Copy link" msgstr "复制链接" #: authentication/templates/authentication/login_wait_confirm.html:51 +#: templates/flash_message_standalone.html:38 msgid "Return" msgstr "返回" @@ -1529,19 +1546,52 @@ msgstr "返回" msgid "Copy success" msgstr "复制成功" -#: authentication/views/login.py:63 authentication/views/login.py:313 -msgid "Redirecting" -msgstr "跳转中" +#: authentication/views/dingtalk.py:40 authentication/views/wecom.py:40 +msgid "You've been hacked" +msgstr "" -#: authentication/views/login.py:64 -msgid "Redirecting to {} authentication" -msgstr "正在跳转到 {} 认证" +#: authentication/views/dingtalk.py:76 +msgid "DingTalk is already bound" +msgstr "" -#: authentication/views/login.py:88 +#: authentication/views/dingtalk.py:89 authentication/views/wecom.py:88 +msgid "Please verify your password first" +msgstr "请检查密码" + +#: authentication/views/dingtalk.py:113 authentication/views/wecom.py:112 +msgid "Invalid user_id" +msgstr "无效的 user_id" + +#: authentication/views/dingtalk.py:129 +msgid "DingTalk query user failed" +msgstr "" + +#: authentication/views/dingtalk.py:136 authentication/views/dingtalk.py:219 +#: authentication/views/dingtalk.py:220 +msgid "Binding DingTalk successfully" +msgstr "绑定 钉钉 成功" + +#: authentication/views/dingtalk.py:188 +msgid "Failed to get user from DingTalk" +msgstr "" + +#: authentication/views/dingtalk.py:194 +msgid "DingTalk is not bound" +msgstr "" + +#: authentication/views/dingtalk.py:195 authentication/views/wecom.py:193 +msgid "Please login with a password and then bind the WoCom" +msgstr "" + +#: authentication/views/dingtalk.py:237 authentication/views/dingtalk.py:238 +msgid "Binding DingTalk failed" +msgstr "" + +#: authentication/views/login.py:55 msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: authentication/views/login.py:213 +#: authentication/views/login.py:173 msgid "" "Wait for {} confirm, You also can copy link to her/him
\n" " Don't close this page" @@ -1549,27 +1599,48 @@ msgstr "" "等待 {} 确认, 你也可以复制链接发给他/她
\n" " 不要关闭本页面" -#: authentication/views/login.py:218 +#: authentication/views/login.py:178 msgid "No ticket found" msgstr "没有发现工单" -#: authentication/views/login.py:250 +#: authentication/views/login.py:210 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:251 +#: authentication/views/login.py:211 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" -#: authentication/views/login.py:266 authentication/views/login.py:281 -#: authentication/views/login.py:297 +#: authentication/views/login.py:226 authentication/views/login.py:241 +#: authentication/views/login.py:257 msgid "Please change your password" msgstr "请修改密码" -#: authentication/views/login.py:314 -msgid "Redirect to third party auth" +#: authentication/views/wecom.py:75 +msgid "WeCom is already bound" msgstr "" +#: authentication/views/wecom.py:127 +msgid "WeCom query user failed" +msgstr "" + +#: authentication/views/wecom.py:134 authentication/views/wecom.py:217 +#: authentication/views/wecom.py:218 +msgid "Binding WeCom successfully" +msgstr "绑定 企业微信 成功" + +#: authentication/views/wecom.py:186 +msgid "Failed to get user from WeCom" +msgstr "" + +#: authentication/views/wecom.py:192 +msgid "WeCom is not bound" +msgstr "没有绑定企业微信" + +#: authentication/views/wecom.py:235 authentication/views/wecom.py:236 +msgid "Binding WeCom failed" +msgstr "绑定企业微信失败" + #: common/const/__init__.py:6 #, python-format msgid "%(name)s was created successfully" @@ -1649,7 +1720,15 @@ msgstr "" msgid "Encrypt field using Secret Key" msgstr "" -#: common/mixins/api.py:52 +#: common/message/backends/exceptions.py:23 +msgid "Network error, please contact system administrator" +msgstr "网络错误,请联系系统管理员" + +#: common/message/backends/wecom/__init__.py:19 +msgid "WeCom error, please contact system administrator" +msgstr "企业微信错误,请联系系统管理员" + +#: common/mixins/api.py:57 msgid "Request file format may be wrong" msgstr "上传的文件格式错误 或 其它类型资源的文件" @@ -1677,7 +1756,7 @@ msgstr "字段必须唯一" msgid "Should not contains special characters" msgstr "不能包含特殊字符" -#: jumpserver/context_processor.py:17 +#: jumpserver/context_processor.py:19 msgid "JumpServer Open Source Bastion Host" msgstr "JumpServer 开源堡垒机" @@ -1685,7 +1764,7 @@ msgstr "JumpServer 开源堡垒机" msgid "

Flow service unavailable, check it

" msgstr "" -#: jumpserver/views/other.py:25 +#: jumpserver/views/other.py:27 msgid "" "
Luna is a separately deployed program, you need to deploy Luna, koko, " "configure nginx for url distribution,
If you see this page, " @@ -1694,11 +1773,11 @@ msgstr "" "
Luna是单独部署的一个程序,你需要部署luna,koko,
如果你看到了" "这个页面,证明你访问的不是nginx监听的端口,祝你好运
" -#: jumpserver/views/other.py:69 +#: jumpserver/views/other.py:71 msgid "Websocket server run on port: {}, you should proxy it on nginx" msgstr "Websocket 服务运行在端口: {}, 请检查nginx是否代理是否设置" -#: jumpserver/views/other.py:83 +#: jumpserver/views/other.py:85 msgid "" "
Koko is a separately deployed program, you need to deploy Koko, " "configure nginx for url distribution,
If you see this page, " @@ -1708,6 +1787,20 @@ msgstr "" "div>
如果你看到了这个页面,证明你访问的不是nginx监听的端口,祝你好运" +#: notifications/models.py:17 users/forms/profile.py:101 +#: users/models/user.py:557 +msgid "Email" +msgstr "邮件" + +#: notifications/models.py:67 templates/_nav.html:110 terminal/apps.py:9 +#: terminal/serializers/session.py:40 +msgid "Terminal" +msgstr "终端" + +#: notifications/models.py:68 ops/apps.py:9 +msgid "Operations" +msgstr "运维" + #: ops/api/celery.py:61 ops/api/celery.py:76 msgid "Waiting task start" msgstr "等待任务开始" @@ -1811,7 +1904,7 @@ msgid "Time" msgstr "时间" #: ops/models/adhoc.py:246 ops/models/command.py:28 -#: terminal/serializers/session.py:38 +#: terminal/serializers/session.py:41 msgid "Is finished" msgstr "是否完成" @@ -1843,10 +1936,18 @@ msgstr "任务开始" msgid "Command `{}` is forbidden ........" msgstr "命令 `{}` 不允许被执行 ......." -#: ops/models/command.py:113 +#: ops/models/command.py:115 msgid "Task end" msgstr "任务结束" +#: ops/notifications.py:10 +msgid "Server performance" +msgstr "服务器性能" + +#: ops/notifications.py:17 +msgid "Disk used more than 80%: {} => {}" +msgstr "磁盘使用率超过 80%: {} => {}" + #: ops/tasks.py:71 msgid "Clean task history period" msgstr "定期清除任务历史" @@ -1863,10 +1964,6 @@ msgstr "任务列表" msgid "Update task content: {}" msgstr "更新任务内容: {}" -#: ops/utils.py:74 -msgid "Disk used more than 80%: {} => {}" -msgstr "磁盘使用率超过 80%: {} => {}" - #: orgs/api.py:76 #, python-brace-format msgid "Have `{model._meta.verbose_name}` exists, Please delete" @@ -1877,8 +1974,8 @@ msgid "The current organization cannot be deleted" msgstr "当前组织不能被删除" #: orgs/mixins/models.py:45 orgs/mixins/serializers.py:25 orgs/models.py:36 -#: orgs/models.py:417 orgs/serializers.py:101 -#: tickets/serializers/ticket/ticket.py:81 +#: orgs/models.py:417 orgs/serializers.py:108 +#: tickets/serializers/ticket/ticket.py:83 msgid "Organization" msgstr "组织" @@ -1948,7 +2045,7 @@ msgid "Clipboard copy paste" msgstr "剪贴板复制粘贴" #: perms/models/asset_permission.py:102 -#: perms/serializers/asset/permission.py:69 +#: perms/serializers/asset/permission.py:71 msgid "Actions" msgstr "动作" @@ -1989,49 +2086,41 @@ msgid "" "permission type. ({})" msgstr "应用列表中包含与授权类型不同的应用。({})" -#: perms/serializers/asset/permission.py:43 -#: perms/serializers/asset/permission.py:67 users/serializers/user.py:34 -#: users/serializers/user.py:70 +#: perms/serializers/asset/permission.py:45 +#: perms/serializers/asset/permission.py:69 users/serializers/user.py:34 +#: users/serializers/user.py:82 msgid "Is expired" msgstr "是否过期" -#: perms/serializers/asset/permission.py:44 -#, fuzzy -#| msgid "Username" +#: perms/serializers/asset/permission.py:46 msgid "Users name" msgstr "用户名" -#: perms/serializers/asset/permission.py:45 -#, fuzzy -#| msgid "User groups amount" +#: perms/serializers/asset/permission.py:47 msgid "User groups name" msgstr "用户组数量" -#: perms/serializers/asset/permission.py:46 -#, fuzzy -#| msgid "Asset num" -msgid "Assets name" -msgstr "资产数量" - #: perms/serializers/asset/permission.py:48 -#, fuzzy -#| msgid "System users amount" -msgid "System users name" -msgstr "系统用户数量" +msgid "Assets name" +msgstr "资产名字" -#: perms/serializers/asset/permission.py:68 users/serializers/user.py:69 +#: perms/serializers/asset/permission.py:50 +msgid "System users name" +msgstr "系统用户名字" + +#: perms/serializers/asset/permission.py:70 users/serializers/user.py:81 msgid "Is valid" msgstr "账户是否有效" -#: perms/serializers/asset/permission.py:70 users/serializers/group.py:36 +#: perms/serializers/asset/permission.py:72 users/serializers/group.py:36 msgid "Users amount" msgstr "用户数量" -#: perms/serializers/asset/permission.py:71 +#: perms/serializers/asset/permission.py:73 msgid "User groups amount" msgstr "用户组数量" -#: perms/serializers/asset/permission.py:74 +#: perms/serializers/asset/permission.py:76 msgid "System users amount" msgstr "系统用户数量" @@ -2044,6 +2133,10 @@ msgstr "邮件已经发送{}, 请检查" msgid "Welcome to the JumpServer open source Bastion Host" msgstr "欢迎使用JumpServer开源堡垒机" +#: settings/api/dingtalk.py:36 settings/api/wecom.py:36 +msgid "OK" +msgstr "" + #: settings/api/ldap.py:189 msgid "Get ldap users is None" msgstr "获取 LDAP 用户为 None" @@ -2379,6 +2472,38 @@ msgstr "邮件收件人" msgid "Multiple user using , split" msgstr "多个用户,使用 , 分割" +#: settings/serializers/settings.py:193 +msgid "Corporation ID" +msgstr "企业 ID(CorpId)" + +#: settings/serializers/settings.py:194 +msgid "Agent ID" +msgstr "应用 ID(AgentId)" + +#: settings/serializers/settings.py:195 +msgid "Corporation Secret" +msgstr "凭证密钥(Secret)" + +#: settings/serializers/settings.py:196 +msgid "Enable WeCom Auth" +msgstr "启用企业微信认证" + +#: settings/serializers/settings.py:200 +msgid "AgentId" +msgstr "应用 ID(AgentId)" + +#: settings/serializers/settings.py:201 +msgid "AppKey" +msgstr "应用 Key(AppKey)" + +#: settings/serializers/settings.py:202 +msgid "AppSecret" +msgstr "应用密文(AppSecret)" + +#: settings/serializers/settings.py:203 +msgid "Enable DingTalk Auth" +msgstr "启用钉钉认证" + #: settings/utils/ldap.py:411 msgid "Host or port is disconnected: {}" msgstr "主机或端口不可连接: {}" @@ -2685,10 +2810,6 @@ msgstr "Web终端" msgid "File manager" msgstr "文件管理" -#: templates/_nav.html:110 terminal/serializers/session.py:37 -msgid "Terminal" -msgstr "终端" - #: templates/_nav.html:121 msgid "Job Center" msgstr "作业中心" @@ -2774,14 +2895,6 @@ msgstr "确认删除" msgid "Are you sure delete" msgstr "您确定删除吗?" -#: templates/flash_message_standalone.html:28 -msgid "Cancel" -msgstr "取消" - -#: templates/flash_message_standalone.html:37 -msgid "Go" -msgstr "跳转" - #: templates/index.html:11 msgid "Total users" msgstr "用户总数" @@ -3111,27 +3224,110 @@ msgstr "命令存储" msgid "Replay storage" msgstr "录像存储" -#: terminal/serializers/session.py:30 +#: terminal/notifications.py:29 +msgid "Terminal command alert" +msgstr "终端命令告警" + +#: terminal/notifications.py:38 +#, python-format +msgid "" +"\n" +" Command: %(command)s\n" +"
\n" +" Asset: %(host_name)s (%(host_ip)s)\n" +"
\n" +" User: %(user)s\n" +"
\n" +" Level: %(risk_level)s\n" +"
\n" +" Session: session " +"detail\n" +"
\n" +" " +msgstr "" +"\n" +" 命令: %(command)s\n" +"
\n" +" 资产: %(host_name)s (%(host_ip)s)\n" +"
\n" +" 用户: %(user)s\n" +"
\n" +" 等级: %(risk_level)s\n" +"
\n" +" 会话: 会话详情\n" +"
\n" +" " + +#: terminal/notifications.py:73 +#, python-format +msgid "" +"Insecure Command Alert: [%(name)s->%(login_from)s@%(remote_addr)s] $" +"%(command)s" +msgstr "危险命令告警: [%(name)s->%(login_from)s@%(remote_addr)s] $%(command)s" + +#: terminal/notifications.py:90 +msgid "Batch command alert" +msgstr "批量命令告警" + +#: terminal/notifications.py:101 +#, python-format +msgid "" +"\n" +"
\n" +" Assets: %(assets)s\n" +"
\n" +" User: %(user)s\n" +"
\n" +" Level: %(risk_level)s\n" +"
\n" +"\n" +" ----------------- Commands ---------------- " +"
\n" +" %(command)s
\n" +" ----------------- Commands ---------------- " +"
\n" +" " +msgstr "" +"\n" +"
\n" +" 资产: %(assets)s\n" +"
\n" +" 用户: %(user)s\n" +"
\n" +" 等级: %(risk_level)s\n" +"
\n" +"\n" +" ----------------- 命令 ----------------
\n" +" %(command)s
\n" +" ----------------- 命令 ----------------
\n" +" " + +#: terminal/notifications.py:127 +#, python-format +msgid "Insecure Web Command Execution Alert: [%(name)s]" +msgstr "Web页面-> 命令执行 告警: [%(name)s]" + +#: terminal/serializers/session.py:33 msgid "User ID" msgstr "用户 ID" -#: terminal/serializers/session.py:31 +#: terminal/serializers/session.py:34 msgid "Asset ID" msgstr "资产 ID" -#: terminal/serializers/session.py:32 +#: terminal/serializers/session.py:35 msgid "System user ID" msgstr "系统用户 ID" -#: terminal/serializers/session.py:33 +#: terminal/serializers/session.py:36 msgid "Login from for display" msgstr "登录来源(显示名称)" -#: terminal/serializers/session.py:35 +#: terminal/serializers/session.py:38 msgid "Can replay" msgstr "是否可重放" -#: terminal/serializers/session.py:36 +#: terminal/serializers/session.py:39 msgid "Can join" msgstr "是否可加入" @@ -3200,82 +3396,10 @@ msgstr "文档类型" msgid "Ignore Certificate Verification" msgstr "忽略证书认证" -#: terminal/serializers/terminal.py:66 terminal/serializers/terminal.py:74 +#: terminal/serializers/terminal.py:73 terminal/serializers/terminal.py:81 msgid "Not found" msgstr "没有发现" -#: terminal/utils.py:78 -#, python-format -msgid "" -"Insecure Command Alert: [%(name)s->%(login_from)s@%(remote_addr)s] $" -"%(command)s" -msgstr "危险命令告警: [%(name)s->%(login_from)s@%(remote_addr)s] $%(command)s" - -#: terminal/utils.py:86 -#, python-format -msgid "" -"\n" -" Command: %(command)s\n" -"
\n" -" Asset: %(host_name)s (%(host_ip)s)\n" -"
\n" -" User: %(user)s\n" -"
\n" -" Level: %(risk_level)s\n" -"
\n" -" Session: session detail\n" -"
\n" -" " -msgstr "" -"\n" -" 命令: %(command)s\n" -"
\n" -" 资产: %(host_name)s (%(host_ip)s)\n" -"
\n" -" 用户: %(user)s\n" -"
\n" -" 等级: %(risk_level)s\n" -"
\n" -" 会话: 会话详情\n" -"
\n" -" " - -#: terminal/utils.py:113 -#, python-format -msgid "Insecure Web Command Execution Alert: [%(name)s]" -msgstr "Web页面-> 命令执行 告警: [%(name)s]" - -#: terminal/utils.py:121 -#, python-format -msgid "" -"\n" -"
\n" -" Assets: %(assets)s\n" -"
\n" -" User: %(user)s\n" -"
\n" -" Level: %(risk_level)s\n" -"
\n" -"\n" -" ----------------- Commands ----------------
\n" -" %(command)s
\n" -" ----------------- Commands ----------------
\n" -" " -msgstr "" -"\n" -"
\n" -" 资产: %(assets)s\n" -"
\n" -" 用户: %(user)s\n" -"
\n" -" 等级: %(risk_level)s\n" -"
\n" -"\n" -" ----------------- 命令 ----------------
\n" -" %(command)s
\n" -" ----------------- 命令 ----------------
\n" -" " - #: tickets/const.py:8 msgid "General" msgstr "一般" @@ -3636,13 +3760,13 @@ msgstr "动作 (显示名称)" msgid "Status display" msgstr "状态(显示名称)" -#: tickets/serializers/ticket/ticket.py:99 +#: tickets/serializers/ticket/ticket.py:101 msgid "" "The `type` in the submission data (`{}`) is different from the type in the " "request url (`{}`)" msgstr "提交数据中的类型 (`{}`) 与请求URL地址中的类型 (`{}`) 不一致" -#: tickets/serializers/ticket/ticket.py:120 +#: tickets/serializers/ticket/ticket.py:122 msgid "None of the assignees belong to Organization `{}` admins" msgstr "所有受理人都不属于组织 `{}` 下的管理员" @@ -3712,10 +3836,6 @@ msgstr "确认密码" msgid "Password does not match" msgstr "密码不一致" -#: users/forms/profile.py:101 users/models/user.py:557 -msgid "Email" -msgstr "邮件" - #: users/forms/profile.py:108 msgid "Old password" msgstr "原来密码" @@ -3781,11 +3901,11 @@ msgstr "用户来源" msgid "Date password last updated" msgstr "最后更新密码日期" -#: users/models/user.py:741 +#: users/models/user.py:751 msgid "Administrator" msgstr "管理员" -#: users/models/user.py:744 +#: users/models/user.py:754 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" @@ -3793,7 +3913,7 @@ msgstr "Administrator是初始的超级管理员" msgid "The old password is incorrect" msgstr "旧密码错误" -#: users/serializers/profile.py:36 users/serializers/user.py:113 +#: users/serializers/profile.py:36 users/serializers/user.py:125 msgid "Password does not match security rules" msgstr "密码不满足安全规则" @@ -3805,7 +3925,7 @@ msgstr "新密码不能是最近 {} 次的密码" msgid "The newly set password is inconsistent" msgstr "两次密码不一致" -#: users/serializers/profile.py:121 users/serializers/user.py:68 +#: users/serializers/profile.py:121 users/serializers/user.py:80 msgid "Is first login" msgstr "首次登录" @@ -3846,35 +3966,35 @@ msgstr "是否可更新" msgid "Can delete" msgstr "是否可删除" -#: users/serializers/user.py:39 users/serializers/user.py:75 +#: users/serializers/user.py:39 users/serializers/user.py:87 msgid "Organization role name" msgstr "组织角色名称" -#: users/serializers/user.py:71 +#: users/serializers/user.py:83 msgid "Avatar url" msgstr "头像路径" -#: users/serializers/user.py:73 +#: users/serializers/user.py:85 msgid "Groups name" msgstr "用户组名" -#: users/serializers/user.py:74 +#: users/serializers/user.py:86 msgid "Source name" msgstr "用户来源名" -#: users/serializers/user.py:76 +#: users/serializers/user.py:88 msgid "Super role name" msgstr "超级角色名称" -#: users/serializers/user.py:77 +#: users/serializers/user.py:89 msgid "Total role name" msgstr "汇总角色名称" -#: users/serializers/user.py:101 +#: users/serializers/user.py:113 msgid "Role limit to {}" msgstr "角色只能为 {}" -#: users/serializers/user.py:198 +#: users/serializers/user.py:210 msgid "name not unique" msgstr "名称重复" @@ -4925,12 +5045,85 @@ msgstr "旗舰版" msgid "Community edition" msgstr "社区版" -#~ msgid "The administrator require you to change your password this time" -#~ msgstr "管理员要求您本次修改密码" +#, python-format +#~ msgid "" +#~ "\n" +#~ " Command: %(command)s\n" +#~ "
\n" +#~ " Asset: %(host_name)s (%(host_ip)s)\n" +#~ "
\n" +#~ " User: %(user)s\n" +#~ "
\n" +#~ " Level: %(risk_level)s\n" +#~ "
\n" +#~ " Session: session detail\n" +#~ "
\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ " 命令: %(command)s\n" +#~ "
\n" +#~ " 资产: %(host_name)s (%(host_ip)s)\n" +#~ "
\n" +#~ " 用户: %(user)s\n" +#~ "
\n" +#~ " 等级: %(risk_level)s\n" +#~ "
\n" +#~ " 会话: 会话详情\n" +#~ "
\n" +#~ " " + +#, python-format +#~ msgid "" +#~ "\n" +#~ "
\n" +#~ " Assets: %(assets)s\n" +#~ "
\n" +#~ " User: %(user)s\n" +#~ "
\n" +#~ " Level: %(risk_level)s\n" +#~ "
\n" +#~ "\n" +#~ " ----------------- Commands ----------------
\n" +#~ " %(command)s
\n" +#~ " ----------------- Commands ----------------
\n" +#~ " " +#~ msgstr "" +#~ "\n" +#~ "
\n" +#~ " 资产: %(assets)s\n" +#~ "
\n" +#~ " 用户: %(user)s\n" +#~ "
\n" +#~ " 等级: %(risk_level)s\n" +#~ "
\n" +#~ "\n" +#~ " ----------------- 命令 ----------------
\n" +#~ " %(command)s
\n" +#~ " ----------------- 命令 ----------------
\n" +#~ " " + +#~ msgid "Ops" +#~ msgstr "选项" + +#~ msgid "Command Alert" +#~ msgstr "命令告警" + +#~ msgid "Agent Secret" +#~ msgstr "凭证密钥(secret)" + +#~ msgid "APP key" +#~ msgstr "APPKEY" + +#~ msgid "APP secret" +#~ msgstr "LDAP 地址" #~ msgid "Auth" #~ msgstr "认证" +#~ msgid "The administrator require you to change your password this time" +#~ msgstr "管理员要求您本次修改密码" + #~ msgid "Security and Role" #~ msgstr "角色安全" @@ -4985,9 +5178,6 @@ msgstr "社区版" #~ msgid "Join" #~ msgstr "加入" -#~ msgid "Update successfully!" -#~ msgstr "更新成功" - #~ msgid "Goto profile page enable MFA" #~ msgstr "请去个人信息页面启用自己的多因子认证" @@ -5000,6 +5190,9 @@ msgstr "社区版" #~ msgid "This will reset the user password and send a reset mail" #~ msgstr "将失效用户当前密码,并发送重设密码邮件到用户邮箱" +#~ msgid "Cancel" +#~ msgstr "取消" + #~ msgid "" #~ "The reset-ssh-public-key E-mail has been sent successfully. Please inform " #~ "the user to update his new ssh public key." diff --git a/apps/ops/apps.py b/apps/ops/apps.py index 01dfd05fa..8bdc04ce8 100644 --- a/apps/ops/apps.py +++ b/apps/ops/apps.py @@ -1,10 +1,12 @@ from __future__ import unicode_literals +from django.utils.translation import gettext_lazy as _ from django.apps import AppConfig class OpsConfig(AppConfig): name = 'ops' + verbose_name = _('Operations') def ready(self): from orgs.models import Organization diff --git a/apps/orgs/signals_handler/common.py b/apps/orgs/signals_handler/common.py index f59c4cb47..6beecd5cb 100644 --- a/apps/orgs/signals_handler/common.py +++ b/apps/orgs/signals_handler/common.py @@ -8,7 +8,6 @@ from django.dispatch import receiver from django.utils.functional import LazyObject from django.db.models.signals import m2m_changed from django.db.models.signals import post_save, post_delete, pre_delete -from django.utils.translation import ugettext as _ from orgs.utils import tmp_to_org from orgs.models import Organization, OrganizationMember @@ -19,7 +18,6 @@ from common.const.signals import PRE_REMOVE, POST_REMOVE from common.signals import django_ready from common.utils import get_logger from common.utils.connection import RedisPubSub -from common.exceptions import JMSException logger = get_logger(__file__) diff --git a/apps/perms/api/asset/user_permission/mixin.py b/apps/perms/api/asset/user_permission/mixin.py index 3b8733b3b..c0f25b8a4 100644 --- a/apps/perms/api/asset/user_permission/mixin.py +++ b/apps/perms/api/asset/user_permission/mixin.py @@ -3,8 +3,9 @@ from rest_framework.request import Request from common.permissions import IsOrgAdminOrAppUser, IsValidUser -from common.utils import lazyproperty from common.http import is_true +from common.mixins.api import RoleAdminMixin as _RoleAdminMixin +from common.mixins.api import RoleUserMixin as _RoleUserMixin from orgs.utils import tmp_to_root_org from users.models import User from perms.utils.asset.user_permission import UserGrantedTreeRefreshController @@ -20,24 +21,13 @@ class PermBaseMixin: return super().get(request, *args, **kwargs) -class RoleAdminMixin(PermBaseMixin): +class RoleAdminMixin(PermBaseMixin, _RoleAdminMixin): permission_classes = (IsOrgAdminOrAppUser,) - kwargs: dict - - @lazyproperty - def user(self): - user_id = self.kwargs.get('pk') - return User.objects.get(id=user_id) -class RoleUserMixin(PermBaseMixin): +class RoleUserMixin(PermBaseMixin, _RoleUserMixin): permission_classes = (IsValidUser,) - request: Request def get(self, request, *args, **kwargs): with tmp_to_root_org(): return super().get(request, *args, **kwargs) - - @lazyproperty - def user(self): - return self.request.user diff --git a/apps/settings/api/__init__.py b/apps/settings/api/__init__.py index 151617be5..39e009ed5 100644 --- a/apps/settings/api/__init__.py +++ b/apps/settings/api/__init__.py @@ -1,2 +1,4 @@ from .common import * from .ldap import * +from .wecom import * +from .dingtalk import * diff --git a/apps/settings/api/common.py b/apps/settings/api/common.py index 1fa578c50..bb3107dfd 100644 --- a/apps/settings/api/common.py +++ b/apps/settings/api/common.py @@ -125,7 +125,9 @@ class PublicSettingApi(generics.RetrieveAPIView): 'SECURITY_PASSWORD_LOWER_CASE': settings.SECURITY_PASSWORD_LOWER_CASE, 'SECURITY_PASSWORD_NUMBER': settings.SECURITY_PASSWORD_NUMBER, 'SECURITY_PASSWORD_SPECIAL_CHAR': settings.SECURITY_PASSWORD_SPECIAL_CHAR, - } + }, + "AUTH_WECOM": settings.AUTH_WECOM, + "AUTH_DINGTALK": settings.AUTH_DINGTALK, } } return instance @@ -141,6 +143,8 @@ class SettingsApi(generics.RetrieveUpdateAPIView): 'ldap': serializers.LDAPSettingSerializer, 'email': serializers.EmailSettingSerializer, 'email_content': serializers.EmailContentSettingSerializer, + 'wecom': serializers.WeComSettingSerializer, + 'dingtalk': serializers.DingTalkSettingSerializer, } def get_serializer_class(self): @@ -163,6 +167,8 @@ class SettingsApi(generics.RetrieveUpdateAPIView): category = self.request.query_params.get('category', '') for name, value in serializer.validated_data.items(): encrypted = name in encrypted_items + if encrypted and value in ['', None]: + continue data.append({ 'name': name, 'value': value, 'encrypted': encrypted, 'category': category diff --git a/apps/settings/api/dingtalk.py b/apps/settings/api/dingtalk.py new file mode 100644 index 000000000..e560f8626 --- /dev/null +++ b/apps/settings/api/dingtalk.py @@ -0,0 +1,38 @@ +import requests + +from rest_framework.views import Response +from rest_framework.generics import GenericAPIView +from django.utils.translation import gettext_lazy as _ + +from common.permissions import IsSuperUser +from common.message.backends.dingtalk import URL + +from .. import serializers + + +class DingTalkTestingAPI(GenericAPIView): + permission_classes = (IsSuperUser,) + serializer_class = serializers.DingTalkSettingSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + dingtalk_appkey = serializer.validated_data['DINGTALK_APPKEY'] + dingtalk_agentid = serializer.validated_data['DINGTALK_AGENTID'] + dingtalk_appsecret = serializer.validated_data['DINGTALK_APPSECRET'] + + try: + params = {'appkey': dingtalk_appkey, 'appsecret': dingtalk_appsecret} + resp = requests.get(url=URL.GET_TOKEN, params=params) + if resp.status_code != 200: + return Response(status=400, data={'error': resp.json()}) + + data = resp.json() + errcode = data['errcode'] + if errcode != 0: + return Response(status=400, data={'error': data['errmsg']}) + + return Response(status=200, data={'msg': _('OK')}) + except Exception as e: + return Response(status=400, data={'error': str(e)}) diff --git a/apps/settings/api/wecom.py b/apps/settings/api/wecom.py new file mode 100644 index 000000000..0fda33c61 --- /dev/null +++ b/apps/settings/api/wecom.py @@ -0,0 +1,38 @@ +import requests + +from rest_framework.views import Response +from rest_framework.generics import GenericAPIView +from django.utils.translation import gettext_lazy as _ + +from common.permissions import IsSuperUser +from common.message.backends.wecom import URL + +from .. import serializers + + +class WeComTestingAPI(GenericAPIView): + permission_classes = (IsSuperUser,) + serializer_class = serializers.WeComSettingSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + wecom_corpid = serializer.validated_data['WECOM_CORPID'] + wecom_agentid = serializer.validated_data['WECOM_AGENTID'] + wecom_corpsecret = serializer.validated_data['WECOM_CORPSECRET'] + + try: + params = {'corpid': wecom_corpid, 'corpsecret': wecom_corpsecret} + resp = requests.get(url=URL.GET_TOKEN, params=params) + if resp.status_code != 200: + return Response(status=400, data={'error': resp.json()}) + + data = resp.json() + errcode = data['errcode'] + if errcode != 0: + return Response(status=400, data={'error': data['errmsg']}) + + return Response(status=200, data={'msg': _('OK')}) + except Exception as e: + return Response(status=400, data={'error': str(e)}) diff --git a/apps/settings/serializers/settings.py b/apps/settings/serializers/settings.py index b64b95cb6..5d33a1d83 100644 --- a/apps/settings/serializers/settings.py +++ b/apps/settings/serializers/settings.py @@ -6,7 +6,7 @@ from rest_framework import serializers __all__ = [ 'BasicSettingSerializer', 'EmailSettingSerializer', 'EmailContentSettingSerializer', 'LDAPSettingSerializer', 'TerminalSettingSerializer', 'SecuritySettingSerializer', - 'SettingsSerializer' + 'SettingsSerializer', 'WeComSettingSerializer', 'DingTalkSettingSerializer', ] @@ -189,13 +189,29 @@ class SecuritySettingSerializer(serializers.Serializer): ) +class WeComSettingSerializer(serializers.Serializer): + WECOM_CORPID = serializers.CharField(max_length=256, required=True, label=_('Corporation ID')) + WECOM_AGENTID = serializers.CharField(max_length=256, required=True, label=_("Agent ID")) + WECOM_CORPSECRET = serializers.CharField(max_length=256, required=False, label=_("Corporation Secret"), write_only=True) + AUTH_WECOM = serializers.BooleanField(default=False, label=_('Enable WeCom Auth')) + + +class DingTalkSettingSerializer(serializers.Serializer): + DINGTALK_AGENTID = serializers.CharField(max_length=256, required=True, label=_("AgentId")) + DINGTALK_APPKEY = serializers.CharField(max_length=256, required=True, label=_("AppKey")) + DINGTALK_APPSECRET = serializers.CharField(max_length=256, required=False, label=_("AppSecret"), write_only=True) + AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('Enable DingTalk Auth')) + + class SettingsSerializer( BasicSettingSerializer, EmailSettingSerializer, EmailContentSettingSerializer, LDAPSettingSerializer, TerminalSettingSerializer, - SecuritySettingSerializer + SecuritySettingSerializer, + WeComSettingSerializer, + DingTalkSettingSerializer, ): # encrypt_fields 现在使用 write_only 来判断了 diff --git a/apps/settings/urls/api_urls.py b/apps/settings/urls/api_urls.py index 0db9c7c54..86dfc6847 100644 --- a/apps/settings/urls/api_urls.py +++ b/apps/settings/urls/api_urls.py @@ -13,6 +13,8 @@ urlpatterns = [ path('ldap/users/', api.LDAPUserListApi.as_view(), name='ldap-user-list'), path('ldap/users/import/', api.LDAPUserImportAPI.as_view(), name='ldap-user-import'), 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('setting/', api.SettingsApi.as_view(), name='settings-setting'), path('public/', api.PublicSettingApi.as_view(), name='public-setting'), diff --git a/apps/static/img/login_dingtalk_log.png b/apps/static/img/login_dingtalk_log.png new file mode 100644 index 000000000..998f730ad Binary files /dev/null and b/apps/static/img/login_dingtalk_log.png differ diff --git a/apps/static/img/login_wecom_log.png b/apps/static/img/login_wecom_log.png new file mode 100644 index 000000000..d5a58d0ba Binary files /dev/null and b/apps/static/img/login_wecom_log.png differ diff --git a/apps/terminal/apps.py b/apps/terminal/apps.py index 5341369c7..f0cb05bf2 100644 --- a/apps/terminal/apps.py +++ b/apps/terminal/apps.py @@ -1,10 +1,12 @@ from __future__ import unicode_literals +from django.utils.translation import gettext_lazy as _ from django.apps import AppConfig class TerminalConfig(AppConfig): name = 'terminal' + verbose_name = _('Terminal') def ready(self): from . import signals_handler diff --git a/apps/users/migrations/0034_auto_20210506_1448.py b/apps/users/migrations/0034_auto_20210506_1448.py new file mode 100644 index 000000000..df6257064 --- /dev/null +++ b/apps/users/migrations/0034_auto_20210506_1448.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1 on 2021-05-06 06:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0033_user_need_update_password'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='dingtalk_id', + field=models.CharField(default=None, max_length=128, null=True, unique=True), + ), + migrations.AddField( + model_name='user', + name='wecom_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 344e2b6e6..715f02b9d 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -541,7 +541,10 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): cas = 'cas', 'CAS' SOURCE_BACKEND_MAPPING = { - Source.local: [settings.AUTH_BACKEND_MODEL, settings.AUTH_BACKEND_PUBKEY], + Source.local: [ + settings.AUTH_BACKEND_MODEL, settings.AUTH_BACKEND_PUBKEY, + settings.AUTH_BACKEND_WECOM, settings.AUTH_BACKEND_DINGTALK, + ], Source.ldap: [settings.AUTH_BACKEND_LDAP], Source.openid: [settings.AUTH_BACKEND_OIDC_PASSWORD, settings.AUTH_BACKEND_OIDC_CODE], Source.radius: [settings.AUTH_BACKEND_RADIUS], @@ -605,10 +608,20 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): verbose_name=_('Date password last updated') ) need_update_password = models.BooleanField(default=False) + 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) def __str__(self): return '{0.name}({0.username})'.format(self) + @property + def is_wecom_bound(self): + return bool(self.wecom_id) + + @property + def is_dingtalk_bound(self): + return bool(self.dingtalk_id) + def get_absolute_url(self): return reverse('users:user-detail', args=(self.id,)) diff --git a/apps/users/permissions.py b/apps/users/permissions.py new file mode 100644 index 000000000..03534d211 --- /dev/null +++ b/apps/users/permissions.py @@ -0,0 +1,10 @@ +from rest_framework import permissions + +from .utils import is_auth_password_time_valid + + +class IsAuthPasswdTimeValid(permissions.IsAuthenticated): + + def has_permission(self, request, view): + return super().has_permission(request, view) \ + and is_auth_password_time_valid(request.session) diff --git a/apps/users/serializers/user.py b/apps/users/serializers/user.py index 5b2c19d10..d7591360b 100644 --- a/apps/users/serializers/user.py +++ b/apps/users/serializers/user.py @@ -55,6 +55,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', ] # 包含不太常用的字段,可以没有 fields_verbose = fields_small + [