From 237d51cff9d1aef7e767ab2e12fece8e2f81b1c6 Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 16 Oct 2019 13:31:42 +0800 Subject: [PATCH 01/55] =?UTF-8?q?[Update]=20=E6=8B=86=E5=88=86filter=20org?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/orgs/mixins/api.py | 6 ++++-- apps/orgs/mixins/models.py | 21 ++------------------- apps/orgs/utils.py | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/apps/orgs/mixins/api.py b/apps/orgs/mixins/api.py index 9de7f2c7d..ef5992da8 100644 --- a/apps/orgs/mixins/api.py +++ b/apps/orgs/mixins/api.py @@ -5,7 +5,7 @@ from rest_framework.viewsets import ModelViewSet from rest_framework_bulk import BulkModelViewSet from common.mixins import CommonApiMixin -from ..utils import set_to_root_org +from ..utils import set_to_root_org, filter_org_queryset from ..models import Organization __all__ = [ @@ -22,7 +22,9 @@ class RootOrgViewMixin: class OrgQuerySetMixin: def get_queryset(self): - queryset = super().get_queryset().all() + queryset = super().get_queryset() + queryset = filter_org_queryset(queryset) + if hasattr(self, 'swagger_fake_view'): return queryset[:1] if hasattr(self, 'action') and self.action == 'list' and \ diff --git a/apps/orgs/mixins/models.py b/apps/orgs/mixins/models.py index 672b975dc..8b07705d4 100644 --- a/apps/orgs/mixins/models.py +++ b/apps/orgs/mixins/models.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # -import traceback from django.db import models from django.utils.translation import ugettext_lazy as _ from django.core.exceptions import ValidationError @@ -9,6 +8,7 @@ from django.core.exceptions import ValidationError from common.utils import get_logger from ..utils import ( set_current_org, get_current_org, current_org, + filter_org_queryset, ) from ..models import Organization @@ -23,24 +23,7 @@ class OrgManager(models.Manager): def get_queryset(self): queryset = super(OrgManager, self).get_queryset() - kwargs = {} - - _current_org = get_current_org() - if _current_org is None: - kwargs['id'] = None - elif _current_org.is_real(): - kwargs['org_id'] = _current_org.id - elif _current_org.is_default(): - queryset = queryset.filter(org_id="") - # - # lines = traceback.format_stack() - # print(">>>>>>>>>>>>>>>>>>>>>>>>>>>>") - # for line in lines[-10:-1]: - # print(line) - # print("<<<<<<<<<<<<<<<<<<<<<<<<<<<<") - - queryset = queryset.filter(**kwargs) - return queryset + return filter_org_queryset(queryset) def all(self): if not current_org: diff --git a/apps/orgs/utils.py b/apps/orgs/utils.py index 8fca26e35..0ac964b50 100644 --- a/apps/orgs/utils.py +++ b/apps/orgs/utils.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # +import traceback from werkzeug.local import LocalProxy from contextlib import contextmanager @@ -82,4 +83,25 @@ def tmp_to_org(org): set_current_org(ori_org) +def filter_org_queryset(queryset): + kwargs = {} + + _current_org = get_current_org() + if _current_org is None: + return queryset.none() + + if _current_org.is_real(): + kwargs['org_id'] = _current_org.id + elif _current_org.is_default(): + kwargs["org_id"] = '' + # + # lines = traceback.format_stack() + # print(">>>>>>>>>>>>>>>>>>>>>>>>>>>>") + # for line in lines[-10:-1]: + # print(line) + # print("<<<<<<<<<<<<<<<<<<<<<<<<<<<<") + queryset = queryset.filter(**kwargs) + return queryset + + current_org = LocalProxy(get_current_org) From c3699889e2ab58861c0212e1de820ddbc7bcfbc7 Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 16 Oct 2019 15:29:44 +0800 Subject: [PATCH 02/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9session?= =?UTF-8?q?=E6=94=AF=E6=8C=81protocol=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/static/js/jumpserver.js | 3 +++ apps/terminal/api/session.py | 2 +- apps/terminal/templates/terminal/session_list.html | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/static/js/jumpserver.js b/apps/static/js/jumpserver.js index d028743be..bb4409a37 100644 --- a/apps/static/js/jumpserver.js +++ b/apps/static/js/jumpserver.js @@ -579,6 +579,9 @@ jumpserver.initServerSideDataTable = function (options) { ajax: { url: options.ajax_url, error: function (jqXHR, textStatus, errorThrown) { + if (jqXHR.responseText && jqXHR.responseText.indexOf("%(value)s") ) { + return + } var msg = gettext("Unknown error occur"); if (jqXHR.responseJSON) { if (jqXHR.responseJSON.error) { diff --git a/apps/terminal/api/session.py b/apps/terminal/api/session.py index 7071ddd33..57120cbfe 100644 --- a/apps/terminal/api/session.py +++ b/apps/terminal/api/session.py @@ -27,7 +27,7 @@ class SessionViewSet(OrgBulkModelViewSet): queryset = Session.objects.all() serializer_class = serializers.SessionSerializer permission_classes = (IsOrgAdminOrAppUser, ) - filter_fields = [ + filterset_fields = [ "user", "asset", "system_user", "remote_addr", "protocol", "terminal", "is_finished", ] diff --git a/apps/terminal/templates/terminal/session_list.html b/apps/terminal/templates/terminal/session_list.html index d23d9be02..1393bd241 100644 --- a/apps/terminal/templates/terminal/session_list.html +++ b/apps/terminal/templates/terminal/session_list.html @@ -72,6 +72,7 @@
  • {% trans 'Asset' %}
  • {% trans 'System user' %}
  • {% trans 'Remote addr' %}
  • +
  • {% trans 'Protocol' %}
  • {#
  • {% trans 'Protocol' %}
  • #} {% endblock %} From 9870d3aa853a34fb8e61452dab6baf691fd10129 Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 16 Oct 2019 15:31:48 +0800 Subject: [PATCH 03/55] =?UTF-8?q?[Bugfix]=20=E4=BF=AE=E5=A4=8D=E5=88=A4?= =?UTF-8?q?=E6=96=AD=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/static/js/jumpserver.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/static/js/jumpserver.js b/apps/static/js/jumpserver.js index bb4409a37..1105561e6 100644 --- a/apps/static/js/jumpserver.js +++ b/apps/static/js/jumpserver.js @@ -579,7 +579,7 @@ jumpserver.initServerSideDataTable = function (options) { ajax: { url: options.ajax_url, error: function (jqXHR, textStatus, errorThrown) { - if (jqXHR.responseText && jqXHR.responseText.indexOf("%(value)s") ) { + if (jqXHR.responseText && jqXHR.responseText.indexOf("%(value)s") !== -1 ) { return } var msg = gettext("Unknown error occur"); From c2f78b117074af4c8f327ed79640612ca969dc5f Mon Sep 17 00:00:00 2001 From: ibuler Date: Mon, 21 Oct 2019 15:15:47 +0800 Subject: [PATCH 04/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9jumpserver,?= =?UTF-8?q?=E6=94=AF=E6=8C=81url=E5=89=8D=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/jumpserver/conf.py | 1 + apps/jumpserver/context_processor.py | 1 + apps/jumpserver/settings.py | 1 + apps/static/js/jumpserver.js | 14 +++++++++----- apps/templates/_foot_js.html | 2 +- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 0df26d149..9bd2953f2 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -393,6 +393,7 @@ defaults = { 'FLOWER_URL': "127.0.0.1:5555", 'DEFAULT_ORG_SHOW_ALL_USERS': True, 'PERIOD_TASK_ENABLED': True, + 'FORCE_SCRIPT_NAME': '', } diff --git a/apps/jumpserver/context_processor.py b/apps/jumpserver/context_processor.py index 91a720fd7..8ddb07701 100644 --- a/apps/jumpserver/context_processor.py +++ b/apps/jumpserver/context_processor.py @@ -18,6 +18,7 @@ def jumpserver_processor(request): 'COPYRIGHT': 'FIT2CLOUD 飞致云' + ' © 2014-2019', 'SECURITY_COMMAND_EXECUTION': settings.SECURITY_COMMAND_EXECUTION, 'SECURITY_MFA_VERIFY_TTL': settings.SECURITY_MFA_VERIFY_TTL, + 'FORCE_SCRIPT_NAME': settings.FORCE_SCRIPT_NAME, } return context diff --git a/apps/jumpserver/settings.py b/apps/jumpserver/settings.py index 6088358f1..3ac458065 100644 --- a/apps/jumpserver/settings.py +++ b/apps/jumpserver/settings.py @@ -654,3 +654,4 @@ CHANNEL_LAYERS = { # Enable internal period task PERIOD_TASK_ENABLED = CONFIG.PERIOD_TASK_ENABLED +FORCE_SCRIPT_NAME = CONFIG.FORCE_SCRIPT_NAME diff --git a/apps/static/js/jumpserver.js b/apps/static/js/jumpserver.js index 1105561e6..faea30dc2 100644 --- a/apps/static/js/jumpserver.js +++ b/apps/static/js/jumpserver.js @@ -137,14 +137,18 @@ function setAjaxCSRFToken() { }); } -function activeNav() { - var url_array = document.location.pathname.split("/"); - var app = url_array[1]; - var resource = url_array[2]; +function activeNav(prefix) { + var path = document.location.pathname; + if (prefix) { + path = path.replace(prefix, ''); + } + var urlArray = path.split("/"); + var app = urlArray[1]; + var resource = urlArray[2]; if (app === '') { $('#index').addClass('active'); } else if (app === 'xpack' && resource === 'cloud') { - var item = url_array[3]; + var item = urlArray[3]; $("#" + app).addClass('active'); $('#' + app + ' #' + resource).addClass('active'); $('#' + app + ' #' + resource + ' #' + item + ' a').css('color', '#ffffff'); diff --git a/apps/templates/_foot_js.html b/apps/templates/_foot_js.html index b33fb814f..6a1062887 100644 --- a/apps/templates/_foot_js.html +++ b/apps/templates/_foot_js.html @@ -9,7 +9,7 @@ + + + + + +
    +
    +
    +
    +
    + +

    + {{ JMS_TITLE }} +

    +
    +

    +
    + Wait for Guanghongwei confirm, You also can copy link to her/his
    + Don't close .... +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + {% include '_copyright.html' %} +
    +
    + 2014-2019 +
    +
    +
    + + + diff --git a/apps/authentication/templates/authentication/new_login.html b/apps/authentication/templates/authentication/xpack_login.html similarity index 100% rename from apps/authentication/templates/authentication/new_login.html rename to apps/authentication/templates/authentication/xpack_login.html diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index 8602daca5..0981f1b09 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -16,5 +16,7 @@ urlpatterns = [ # login path('login/', views.UserLoginView.as_view(), name='login'), path('login/otp/', views.UserLoginOtpView.as_view(), name='login-otp'), + path('login/continue/', views.UserLoginContinueView.as_view(), name='login-continue'), + path('login/wait/', views.UserLoginWaitConfirmView.as_view(), name='login-wait'), path('logout/', views.UserLogoutView.as_view(), name='logout'), ] diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 2a7098ae7..afff9dd45 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -12,13 +12,12 @@ from django.utils.translation import ugettext as _ from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect from django.views.decorators.debug import sensitive_post_parameters -from django.views.generic.base import TemplateView +from django.views.generic.base import TemplateView, View, RedirectView from django.views.generic.edit import FormView from django.conf import settings from common.utils import get_request_ip from users.models import User -from audits.models import UserLoginLog as LoginLog from users.utils import ( check_otp_code, is_block_login, clean_failed_count, get_user_or_tmp_user, set_tmp_user_to_cache, increase_login_failed_count, @@ -31,6 +30,7 @@ from .. import const __all__ = [ 'UserLoginView', 'UserLoginOtpView', 'UserLogoutView', + 'UserLoginContinueView', 'UserLoginWaitConfirmView', ] @@ -40,7 +40,6 @@ __all__ = [ class UserLoginView(FormView): form_class = forms.UserLoginForm form_class_captcha = forms.UserLoginCaptchaForm - redirect_field_name = 'next' key_prefix_captcha = "_LOGIN_INVALID_{}" def get_template_names(self): @@ -52,7 +51,7 @@ class UserLoginView(FormView): if not License.has_valid_license(): return template_name - template_name = 'authentication/new_login.html' + template_name = 'authentication/xpack_login.html' return template_name def get(self, request, *args, **kwargs): @@ -91,7 +90,8 @@ class UserLoginView(FormView): ip = get_request_ip(self.request) # 登陆成功,清除缓存计数 clean_failed_count(username, ip) - return redirect(self.get_success_url()) + self.request.session['auth_password'] = '1' + return self.redirect_to_continue_view() def form_invalid(self, form): # write login failed log @@ -111,6 +111,11 @@ class UserLoginView(FormView): form._errors = old_form.errors return super().form_invalid(form) + @staticmethod + def redirect_to_continue_view(): + continue_url = reverse('authentication:login-continue') + return redirect(continue_url) + def get_form_class(self): ip = get_request_ip(self.request) if cache.get(self.key_prefix_captcha.format(ip)): @@ -118,21 +123,6 @@ class UserLoginView(FormView): else: return self.form_class - def get_success_url(self): - user = get_user_or_tmp_user(self.request) - - if user.otp_enabled and user.otp_secret_key: - # 1,2,mfa_setting & T - return reverse('authentication:login-otp') - elif user.otp_enabled and not user.otp_secret_key: - # 1,2,mfa_setting & F - return reverse('users:user-otp-enable-authentication') - elif not user.otp_enabled: - # 0 & T,F - auth_login(self.request, user) - self.send_auth_signal(success=True, user=user) - return redirect_user_first_login_or_index(self.request, self.redirect_field_name) - def get_context_data(self, **kwargs): context = { 'demo_mode': os.environ.get("DEMO_MODE"), @@ -141,15 +131,6 @@ class UserLoginView(FormView): kwargs.update(context) return super().get_context_data(**kwargs) - def send_auth_signal(self, success=True, user=None, username='', reason=''): - if success: - post_auth_success.send(sender=self.__class__, user=user, request=self.request) - else: - post_auth_failed.send( - sender=self.__class__, username=username, - request=self.request, reason=reason - ) - class UserLoginOtpView(FormView): template_name = 'authentication/login_otp.html' @@ -162,9 +143,8 @@ class UserLoginOtpView(FormView): otp_secret_key = user.otp_secret_key if check_otp_code(otp_secret_key, otp_code): - auth_login(self.request, user) - self.send_auth_signal(success=True, user=user) - return redirect(self.get_success_url()) + self.request.session['auth_otp'] = '1' + return UserLoginView.redirect_to_continue_view() else: self.send_auth_signal( success=False, username=user.username, @@ -175,8 +155,40 @@ class UserLoginOtpView(FormView): ) return super().form_invalid(form) - def get_success_url(self): - return redirect_user_first_login_or_index(self.request, self.redirect_field_name) + def send_auth_signal(self, success=True, user=None, username='', reason=''): + if success: + post_auth_success.send(sender=self.__class__, user=user, request=self.request) + else: + post_auth_failed.send( + sender=self.__class__, username=username, + request=self.request, reason=reason + ) + + +class UserLoginContinueView(RedirectView): + redirect_field_name = 'next' + + def get_redirect_url(self, *args, **kwargs): + if not self.request.session.get('auth_password'): + return reverse('authentication:login') + + user = get_user_or_tmp_user(self.request) + if user.otp_enabled and user.otp_secret_key and \ + not self.request.session.get('auth_otp'): + return reverse('authentication:login-otp') + + self.login_success(user) + if user.otp_enabled and not user.otp_secret_key: + # 1,2,mfa_setting & F + return reverse('users:user-otp-enable-authentication') + url = redirect_user_first_login_or_index( + self.request, self.redirect_field_name + ) + return url + + def login_success(self, user): + auth_login(self.request, user) + self.send_auth_signal(success=True, user=user) def send_auth_signal(self, success=True, user=None, username='', reason=''): if success: @@ -188,6 +200,13 @@ class UserLoginOtpView(FormView): ) +class UserLoginWaitConfirmView(TemplateView): + template_name = 'authentication/login_wait_confirm.html' + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) + + @method_decorator(never_cache, name='dispatch') class UserLogoutView(TemplateView): template_name = 'flash_message_standalone.html' diff --git a/apps/orders/models.py b/apps/orders/models.py index dd2e1624d..b2614bd17 100644 --- a/apps/orders/models.py +++ b/apps/orders/models.py @@ -10,8 +10,9 @@ class Order(CommonModelMixin): ('rejected', _("Rejected")), ('pending', _("Pending")) ) + TYPE_LOGIN_REQUEST = 'login_request' TYPE_CHOICES = ( - ('login_request', _("Login request")), + (TYPE_LOGIN_REQUEST, _("Login request")), ) user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='orders', verbose_name=_("User")) user_display = models.CharField(max_length=128, verbose_name=_("User display name")) @@ -22,7 +23,10 @@ class Order(CommonModelMixin): assignees_display = models.CharField(max_length=128, verbose_name=_("Assignees display name"), blank=True) type = models.CharField(choices=TYPE_CHOICES, max_length=64) - status = models.CharField(choices=STATUS_CHOICES, max_length=16) + status = models.CharField(choices=STATUS_CHOICES, max_length=16, default='pending') + + def __str__(self): + return '{}: {}'.format(self.user_display, self.title) class Meta: ordering = ('date_created',) diff --git a/apps/users/models/user.py b/apps/users/models/user.py index f58422fcd..4c7c54def 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -10,7 +10,9 @@ from django.conf import settings from django.contrib.auth.hashers import make_password from django.contrib.auth.models import AbstractUser from django.core.cache import cache +from django.core.exceptions import ObjectDoesNotExist from django.db import models + from django.utils.translation import ugettext_lazy as _ from django.utils import timezone from django.shortcuts import reverse From d0ba67ed503011a8cadbee60dddb138d85b2d8d5 Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 30 Oct 2019 13:18:11 +0800 Subject: [PATCH 11/55] =?UTF-8?q?[Update]=20=E5=9F=BA=E6=9C=AC=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E7=99=BB=E5=BD=95=E4=BA=8C=E6=AC=A1=E5=AE=A1=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/views/domain.py | 2 +- apps/authentication/api/auth.py | 30 +- apps/authentication/api/token.py | 7 +- apps/authentication/models.py | 11 +- .../authentication/login_wait_confirm.html | 114 ++++-- apps/authentication/urls/api_urls.py | 8 +- apps/authentication/urls/view_urls.py | 4 +- apps/authentication/utils.py | 7 +- apps/authentication/views/login.py | 52 ++- apps/jumpserver/urls.py | 28 +- apps/locale/zh/LC_MESSAGES/django.mo | Bin 80725 -> 81958 bytes apps/locale/zh/LC_MESSAGES/django.po | 375 +++++++++++++----- apps/orders/api.py | 38 ++ apps/orders/apps.py | 4 + apps/orders/models.py | 49 ++- apps/orders/serializers.py | 72 ++++ apps/orders/signals_handler.py | 59 +++ .../orders/login_confirm_order_detail.html | 137 +++++++ .../orders/login_confirm_order_list.html | 91 +++++ apps/orders/urls/__init__.py | 2 + apps/orders/urls/api_urls.py | 20 + apps/orders/urls/views_urls.py | 11 + apps/orders/utils.py | 2 + apps/orders/views.py | 35 +- apps/static/js/jumpserver.js | 2 +- apps/templates/_nav.html | 12 +- apps/users/utils.py | 5 +- 27 files changed, 972 insertions(+), 205 deletions(-) create mode 100644 apps/orders/api.py create mode 100644 apps/orders/serializers.py create mode 100644 apps/orders/signals_handler.py create mode 100644 apps/orders/templates/orders/login_confirm_order_detail.html create mode 100644 apps/orders/templates/orders/login_confirm_order_list.html create mode 100644 apps/orders/urls/__init__.py create mode 100644 apps/orders/urls/api_urls.py create mode 100644 apps/orders/urls/views_urls.py create mode 100644 apps/orders/utils.py diff --git a/apps/assets/views/domain.py b/apps/assets/views/domain.py index 7b4dcfcce..67626b094 100644 --- a/apps/assets/views/domain.py +++ b/apps/assets/views/domain.py @@ -7,7 +7,7 @@ from django.views.generic.detail import SingleObjectMixin from django.utils.translation import ugettext_lazy as _ from django.urls import reverse_lazy, reverse -from common.permissions import PermissionsMixin ,IsOrgAdmin +from common.permissions import PermissionsMixin, IsOrgAdmin from common.const import create_success_msg, update_success_msg from common.utils import get_object_or_none from ..models import Domain, Gateway diff --git a/apps/authentication/api/auth.py b/apps/authentication/api/auth.py index 101d6436e..8b1ab69c0 100644 --- a/apps/authentication/api/auth.py +++ b/apps/authentication/api/auth.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # - import uuid import time @@ -8,19 +7,17 @@ from django.core.cache import cache from django.urls import reverse from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext as _ - from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.generics import CreateAPIView from rest_framework.views import APIView -from common.utils import get_logger, get_request_ip +from common.utils import get_logger, get_request_ip, get_object_or_none from common.permissions import IsOrgAdminOrAppUser, IsValidUser from orgs.mixins.api import RootOrgViewMixin from users.serializers import UserSerializer from users.models import User from assets.models import Asset, SystemUser -from audits.models import UserLoginLog as LoginLog from users.utils import ( check_otp_code, increase_login_failed_count, is_block_login, clean_failed_count @@ -33,7 +30,7 @@ from ..signals import post_auth_success, post_auth_failed logger = get_logger(__name__) __all__ = [ 'UserAuthApi', 'UserConnectionTokenApi', 'UserOtpAuthApi', - 'UserOtpVerifyApi', + 'UserOtpVerifyApi', 'UserOrderAcceptAuthApi', ] @@ -209,3 +206,26 @@ class UserOtpVerifyApi(CreateAPIView): else: return Response({"error": "Code not valid"}, status=400) + +class UserOrderAcceptAuthApi(APIView): + permission_classes = () + + def get(self, request, *args, **kwargs): + from orders.models import LoginConfirmOrder + order_id = self.request.session.get("auth_order_id") + logger.debug('Login confirm order id: {}'.format(order_id)) + if not order_id: + order = None + else: + order = get_object_or_none(LoginConfirmOrder, pk=order_id) + if not order: + error = _("No order found or order expired") + return Response({"error": error, "status": "not found"}, status=404) + if order.status == order.STATUS_ACCEPTED: + self.request.session["auth_confirm"] = "1" + return Response({"msg": "ok"}) + elif order.status == order.STATUS_REJECTED: + error = _("Order was rejected by {}").format(order.assignee_display) + else: + error = "Order status: {}".format(order.status) + return Response({"error": error, "status": order.status}, status=400) diff --git a/apps/authentication/api/token.py b/apps/authentication/api/token.py index f44e93609..8855ac1c9 100644 --- a/apps/authentication/api/token.py +++ b/apps/authentication/api/token.py @@ -71,7 +71,8 @@ class TokenCreateApi(CreateAPIView): raise MFARequiredError() self.send_auth_signal(success=True, user=user) clean_failed_count(username, ip) - return super().create(request, *args, **kwargs) + resp = super().create(request, *args, **kwargs) + return resp except AuthFailedError as e: increase_login_failed_count(username, ip) self.send_auth_signal(success=False, user=user, username=username, reason=str(e)) @@ -80,8 +81,8 @@ class TokenCreateApi(CreateAPIView): msg = _("MFA required") seed = uuid.uuid4().hex cache.set(seed, user.username, 300) - resp = {'msg': msg, "choices": ["otp"], "req": seed} - return Response(resp, status=300) + data = {'msg': msg, "choices": ["otp"], "req": seed} + return Response(data, status=300) def send_auth_signal(self, success=True, user=None, username='', reason=''): if success: diff --git a/apps/authentication/models.py b/apps/authentication/models.py index 21fb2aafd..bc92eb8b5 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -49,8 +49,8 @@ class LoginConfirmSetting(CommonModelMixin): return get_object_or_none(cls, user=user) def create_confirm_order(self, request=None): - from orders.models import Order - title = _('User login request confirm: {}'.format(self.user)) + from orders.models import LoginConfirmOrder + title = _('User login request: {}'.format(self.user)) if request: remote_addr = get_request_ip(request) city = get_ip_city(remote_addr) @@ -58,14 +58,17 @@ class LoginConfirmSetting(CommonModelMixin): self.user, remote_addr, city, timezone.now() ) else: + city = '' + remote_addr = '' body = '' reviewer = self.reviewers.all() reviewer_names = ','.join([u.name for u in reviewer]) - order = Order.objects.create( + order = LoginConfirmOrder.objects.create( user=self.user, user_display=str(self.user), title=title, body=body, + city=city, ip=remote_addr, assignees_display=reviewer_names, - type=Order.TYPE_LOGIN_REQUEST, + type=LoginConfirmOrder.TYPE_LOGIN_CONFIRM, ) order.assignees.set(reviewer) return order diff --git a/apps/authentication/templates/authentication/login_wait_confirm.html b/apps/authentication/templates/authentication/login_wait_confirm.html index 54526427e..0a14e8515 100644 --- a/apps/authentication/templates/authentication/login_wait_confirm.html +++ b/apps/authentication/templates/authentication/login_wait_confirm.html @@ -6,13 +6,11 @@ - + {{ title }} - {% include '_head_css_js.html' %} - + @@ -29,23 +27,29 @@

    -
    - Wait for Guanghongwei confirm, You also can copy link to her/his
    - Don't close .... +
    + {{ msg|safe }}
    - +{% include '_foot_js.html' %} + diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index 68dd8eeaa..a90b328cc 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -1,20 +1,15 @@ # coding:utf-8 # - -from __future__ import absolute_import - from django.urls import path from rest_framework.routers import DefaultRouter from .. import api +app_name = 'authentication' router = DefaultRouter() router.register('access-keys', api.AccessKeyViewSet, 'access-key') -app_name = 'authentication' - - urlpatterns = [ # path('token/', api.UserToken.as_view(), name='user-token'), path('auth/', api.UserAuthApi.as_view(), name='user-auth'), @@ -24,6 +19,7 @@ urlpatterns = [ api.UserConnectionTokenApi.as_view(), name='connection-token'), path('otp/auth/', api.UserOtpAuthApi.as_view(), name='user-otp-auth'), path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'), + path('order/auth/', api.UserOrderAcceptAuthApi.as_view(), name='user-order-auth') ] urlpatterns += router.urls diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index 0981f1b09..64d01ae34 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -16,7 +16,7 @@ urlpatterns = [ # login path('login/', views.UserLoginView.as_view(), name='login'), path('login/otp/', views.UserLoginOtpView.as_view(), name='login-otp'), - path('login/continue/', views.UserLoginContinueView.as_view(), name='login-continue'), - path('login/wait/', views.UserLoginWaitConfirmView.as_view(), name='login-wait'), + path('login/wait-confirm/', views.UserLoginWaitConfirmView.as_view(), name='login-wait-confirm'), + path('login/guard/', views.UserLoginGuardView.as_view(), name='login-guard'), path('logout/', views.UserLogoutView.as_view(), name='logout'), ] diff --git a/apps/authentication/utils.py b/apps/authentication/utils.py index 70c7e52fa..85b486bf3 100644 --- a/apps/authentication/utils.py +++ b/apps/authentication/utils.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- # -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext as _, ugettext_lazy as __ from django.contrib.auth import authenticate +from django.utils import timezone -from common.utils import get_ip_city, get_object_or_none, validate_ip +from common.utils import ( + get_ip_city, get_object_or_none, validate_ip, get_request_ip +) from users.models import User from . import const diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index afff9dd45..646268eea 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -3,6 +3,7 @@ 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 @@ -12,17 +13,18 @@ from django.utils.translation import ugettext as _ from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect from django.views.decorators.debug import sensitive_post_parameters -from django.views.generic.base import TemplateView, View, RedirectView +from django.views.generic.base import TemplateView, RedirectView from django.views.generic.edit import FormView from django.conf import settings -from common.utils import get_request_ip +from common.utils import get_request_ip, get_object_or_none from users.models import User from users.utils import ( check_otp_code, is_block_login, clean_failed_count, get_user_or_tmp_user, set_tmp_user_to_cache, increase_login_failed_count, - redirect_user_first_login_or_index, + redirect_user_first_login_or_index ) +from ..models import LoginConfirmSetting from ..signals import post_auth_success, post_auth_failed from .. import forms from .. import const @@ -30,7 +32,7 @@ from .. import const __all__ = [ 'UserLoginView', 'UserLoginOtpView', 'UserLogoutView', - 'UserLoginContinueView', 'UserLoginWaitConfirmView', + 'UserLoginGuardView', 'UserLoginWaitConfirmView', ] @@ -91,7 +93,7 @@ class UserLoginView(FormView): # 登陆成功,清除缓存计数 clean_failed_count(username, ip) self.request.session['auth_password'] = '1' - return self.redirect_to_continue_view() + return self.redirect_to_guard_view() def form_invalid(self, form): # write login failed log @@ -112,8 +114,8 @@ class UserLoginView(FormView): return super().form_invalid(form) @staticmethod - def redirect_to_continue_view(): - continue_url = reverse('authentication:login-continue') + def redirect_to_guard_view(): + continue_url = reverse('authentication:login-guard') return redirect(continue_url) def get_form_class(self): @@ -144,7 +146,7 @@ class UserLoginOtpView(FormView): if check_otp_code(otp_secret_key, otp_code): self.request.session['auth_otp'] = '1' - return UserLoginView.redirect_to_continue_view() + return UserLoginView.redirect_to_guard_view() else: self.send_auth_signal( success=False, username=user.username, @@ -165,7 +167,7 @@ class UserLoginOtpView(FormView): ) -class UserLoginContinueView(RedirectView): +class UserLoginGuardView(RedirectView): redirect_field_name = 'next' def get_redirect_url(self, *args, **kwargs): @@ -173,11 +175,18 @@ class UserLoginContinueView(RedirectView): return reverse('authentication:login') user = get_user_or_tmp_user(self.request) + # 启用并设置了otp if user.otp_enabled and user.otp_secret_key and \ not self.request.session.get('auth_otp'): return reverse('authentication:login-otp') - + confirm_setting = LoginConfirmSetting.get_user_confirm_setting(user) + if confirm_setting and not self.request.session.get('auth_confirm'): + order = confirm_setting.create_confirm_order(self.request) + self.request.session['auth_order_id'] = str(order.id) + url = reverse('authentication:login-wait-confirm') + return url self.login_success(user) + # 启用但是没有设置otp if user.otp_enabled and not user.otp_secret_key: # 1,2,mfa_setting & F return reverse('users:user-otp-enable-authentication') @@ -204,7 +213,28 @@ class UserLoginWaitConfirmView(TemplateView): template_name = 'authentication/login_wait_confirm.html' def get_context_data(self, **kwargs): - return super().get_context_data(**kwargs) + from orders.models import LoginConfirmOrder + order_id = self.request.session.get("auth_order_id") + if not order_id: + order = None + else: + order = get_object_or_none(LoginConfirmOrder, pk=order_id) + context = super().get_context_data(**kwargs) + if order: + order_detail_url = reverse('orders:login-confirm-order-detail', kwargs={'pk': order_id}) + timestamp_created = datetime.datetime.timestamp(order.date_created) + msg = _("""Wait for {} confirm, You also can copy link to her/him
    + Don't close this page""").format(order.assignees_display) + else: + timestamp_created = 0 + order_detail_url = '' + msg = _("No order found") + context.update({ + "msg": msg, + "timestamp": timestamp_created, + "order_detail_url": order_detail_url + }) + return context @method_decorator(never_cache, name='dispatch') diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 129368c47..b9bdb697f 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -13,22 +13,23 @@ from .celery_flower import celery_flower_view from .swagger import get_swagger_view api_v1 = [ - path('users/', include('users.urls.api_urls', namespace='api-users')), - path('assets/', include('assets.urls.api_urls', namespace='api-assets')), - path('perms/', include('perms.urls.api_urls', namespace='api-perms')), - path('terminal/', include('terminal.urls.api_urls', namespace='api-terminal')), - path('ops/', include('ops.urls.api_urls', namespace='api-ops')), - path('audits/', include('audits.urls.api_urls', namespace='api-audits')), - path('orgs/', include('orgs.urls.api_urls', namespace='api-orgs')), - path('settings/', include('settings.urls.api_urls', namespace='api-settings')), - path('authentication/', include('authentication.urls.api_urls', namespace='api-auth')), - path('common/', include('common.urls.api_urls', namespace='api-common')), - path('applications/', include('applications.urls.api_urls', namespace='api-applications')), + path('users/', include('users.urls.api_urls', namespace='api-users')), + path('assets/', include('assets.urls.api_urls', namespace='api-assets')), + path('perms/', include('perms.urls.api_urls', namespace='api-perms')), + path('terminal/', include('terminal.urls.api_urls', namespace='api-terminal')), + path('ops/', include('ops.urls.api_urls', namespace='api-ops')), + path('audits/', include('audits.urls.api_urls', namespace='api-audits')), + path('orgs/', include('orgs.urls.api_urls', namespace='api-orgs')), + path('settings/', include('settings.urls.api_urls', namespace='api-settings')), + path('authentication/', include('authentication.urls.api_urls', namespace='api-auth')), + path('common/', include('common.urls.api_urls', namespace='api-common')), + path('applications/', include('applications.urls.api_urls', namespace='api-applications')), + path('orders/', include('orders.urls.api_urls', namespace='api-orders')), ] api_v2 = [ - path('terminal/', include('terminal.urls.api_urls_v2', namespace='api-terminal-v2')), - path('users/', include('users.urls.api_urls_v2', namespace='api-users-v2')), + path('terminal/', include('terminal.urls.api_urls_v2', namespace='api-terminal-v2')), + path('users/', include('users.urls.api_urls_v2', namespace='api-users-v2')), ] @@ -42,6 +43,7 @@ app_view_patterns = [ path('orgs/', include('orgs.urls.views_urls', namespace='orgs')), path('auth/', include('authentication.urls.view_urls'), name='auth'), path('applications/', include('applications.urls.views_urls', namespace='applications')), + path('orders/', include('orders.urls.views_urls', namespace='orders')), re_path(r'flower/(?P.*)', celery_flower_view, name='flower-view'), ] diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index a6510ca5a41597f874d262fab0c92cdb24ef153b..6f724f936f9dee30ca50475f535c01280cfb31f7 100644 GIT binary patch delta 24950 zcmZwP2YgQF|NrrmNFoF=V<)lqh^9=yEzvp^tzgZ4Pdq2m?f?0An z&P9K$p{(QdZRF&h>$n_?L8m&n?k z*%*QIu{{2aMKNgk~W@5E8bPvRUF#{bNM-5rO;JFPJn?!zP)kJs@} zOopd>xC_0CX^8KlF60erM}2#;0~m&hu>_{T@)*SVow`(#VGFY}YNCFqiN>G?PO|tL z)DF$ZU|fyqaR(;DvzQ34V>sSMEj&>#cR|TfJDMLox)+tH1Y%RnjBQc3WH1KdRBNA) z*@>5Z|5I!+UuWZp#OOZ4M`#II2c*oWHL!~NKQZPh6f zdGQS9!GEn|w*Kyw=0Uv$wXqt0hlTMTro!~BLOYnxEQZ>-a;RHU6ZNn*#WWa$y0CE` zD&D6TwX*rB0ZTCju1DRgJ*WvUSo=-X1dlEM26GZ8j&ZlPAZmPREQB>s&p-@n{t>AD zo{3a+k2a$2;Q`dcbPe?kyueHtG{C(zxy@3j6VyO$Wi!jSLA@QVV-v3Kfw6e#jEByy`OHzF4K18W8A8{tsiK?M?tS;(G8X^-pZ7m;-x+Q~9^G?7} z{04(D4vXSX-gfT)-&8d5JJbsO2f7_oqMqV#)I@nvx2PCu>&uumFod`%YMvfue_Td9 z7fUrlhjOWhN8!NWtJ$0x;N!f6II8u*c^4@X_yb^pcZ@pb!Ep<<1VAd-9|k# z&rzQjDfoer5hGCNtBZPjnhoLp7oqYM3Ek5lQCqwNwZ;2T_xNYj39q1T(QS(#qfYPw zYhZ?|qGqTC_q6sQs1uGx?Z|XSB&WP53Qp+&XiIwI4QbpyvMHdtVQA zC2dew+#PkI7*u{FYT_xV1un681L~nZfV%P{s9SX2;@{Ex^Zz~-o%jW6f`C!(Q=SgB zrQsHrLoJ{NYQl!73EQF;&;@lNgHQ_}iMplJP#3fuwcs_V3)q1kbv#T(C%%Yf@D6I_ zSx37Av*T{!e5ey7pce4Ne1lp@V61z>RH*T}Pz%nFdTmQ!I99??Y#+=1*P-Ge@f1&> zCffCtJHa8;%8#Q~eg<_#*HKsS3~S;$)I(fjjN7jxW+Cp2RdBM!XYfh(Kf%OU4+F3%YJ6)9#O|nt zN2B@;M)mWIqM|Dshq|Y;&F?Ufcq6Lg4%EQ?s0AFe`~}nkuVNrRL$$v#6OZS<6Q{v! zSPONY?#OvP&Oj=_$wDKf=L}Mqc zjXO~n;y2m7bs10#4MSaM1nPn+q4)d$XH;~h9Z)NaF-PGJ;wi{aSf|z$J`?aK)Ro*q zJyZ`+SNzoC7pNV2gSwRkrn>!$V+!K(s0*$;mHpSsnvu`~x?0B}W-RLAnS>f}0F&ZL z48mVg6W+G`UzUH1T43Nb_jkWksGY5V8s8bU;6BsX|CCf>NUX&%*c*#YcfUFqV%Yp%YnL;`A~03S=5fTF#BR!;_;Xc z7oqq2|BqC3;vJ|fKZW`rxrzEQ`5%U1mN~BFQ73AHdKP-3wtg_`sg6bM$T-xMFGuxT zXYD&NJ@G#D=!51G6-{sj%i0z&7y6O=>kKsD68Y=+w6_NX1|k6Pd;)QOj(7P1F5 z?jUO1dDKo_NA2XDx%U1)w2uFw9<~(ocq1?$YJoARr*LqF?{5>clg!FfK)PJY(KQZRy{b8T}Tz zhNHH$yjcx(A@xuTX@e^(jBPrhfoW;fZEZ-OLYIKWTc{}H540Tamh~74qluQf7mx*Y3v!}%v?Qv%A$q_6M^Vv4gHaQBP%9jZ+JU(i zFR}K`sGZn>Dexd_$IhY_at(FGcTp$)2h}fdnR{!}qb@lAGVXs4DwRm+Vd{h#(SxBl z4|PwsqfT%dHPIE!i}z6rPW>IPE@niv$C$CG3!8yj*j&_!mth2M`;PtBfcqqLPhVj{ z3|{VjhL=a>TcNJFALt&TA_A zL<{=foj44&!osL4E^l#7Yj1{{urq2W2B0P!h52y`YJodY^BzOpn%}VjzDA9!vr0S2 z{clc16GWj_{3YsMjYYjC6R{dDM?IwXQTN<`wY$|>Q47w4`aCF!iLo(iA+5|P)WbXo zb)G58>-}FuMJrv0fp`El!3osD&RKlJ+V7%2`KPFbzQiQ>26birKe)Fh6)Mhvx{#cx zovVOJ@iX)UP-#g;CvK05qs+dTlz1R&#iLORn}NFGZ!KPhy3&o97x$qSb`RC>G3sG` zW2RW+eh_6_!~Sb4%ahQRHbq@Y2h>V?pdO}SsD({I?bIyPR<1_f+kF^>*HHcMV;y{B zagDX^0zSv*)Rl}wUBMjG#0yY6vrL!>{SD_Ys z9P{95%fB?!Z*V`z%AlTw2I&3a)s0Fpi81CZa|Noy7A%S9P$vxB=zd?wg!(A0fGMys z>fU$2Y&Ze6khRzrFJKfF+vNV0%mS>&^XJ^4qKR^D=F0|_#X)!#^)sMp93Mg$jXQA; zs(<4j`38(#F$ND|0W7=4eI_DNuWKyU#>L3<=iJBK_{~;*AaVb9QPGdZi&z++qOK%t zn`S&M_VyKhBR)Gersy7vun6t>0}cn#Cw`5o?#-o;#c|Fd#gm9eTh z1`81%#bWpZb)|WC@fXQB20LN;-R_kS#SX+zu{}24<1Rc7YY_jAtuV)4_h-TgEJM5* zJ^CPdNJT6A3p1eqKKBzY6hnwhq27Y}W=k^?b?^J3u6!i=;uuVX<51%!pK{qy3iqNW%)H<2m><<%3^h@COog>j6ShKabq7p|JyG|3IHtpSm-365D2bp0?v>|9U3nGMYuFMsVOP|I!!17-HStQ+gj+B_9z-qd zF$Uu+)CB|{bQh2omCx;=q7|3IaBPfeF&g#bavY|{WvB_ZV0t`&n&7hK@1w5lh2<0f z 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 delta 23844 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\n" "Language-Team: Jumpserver team\n" @@ -144,7 +144,7 @@ msgstr "资产" #: settings/templates/settings/terminal_setting.html:105 terminal/models.py:23 #: terminal/models.py:260 terminal/templates/terminal/terminal_detail.html:43 #: terminal/templates/terminal/terminal_list.html:29 users/models/group.py:14 -#: users/models/user.py:373 users/templates/users/_select_user_modal.html:13 +#: users/models/user.py:375 users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_detail.html:63 #: users/templates/users/user_group_detail.html:55 #: users/templates/users/user_group_list.html:35 @@ -197,7 +197,7 @@ msgstr "参数" #: orgs/models.py:16 perms/models/base.py:54 #: perms/templates/perms/asset_permission_detail.html:98 #: perms/templates/perms/remote_app_permission_detail.html:90 -#: users/models/user.py:414 users/serializers/v1.py:143 +#: users/models/user.py:416 users/serializers/group.py:32 #: users/templates/users/user_detail.html:111 #: xpack/plugins/change_auth_plan/models.py:108 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:113 @@ -219,7 +219,8 @@ msgstr "创建者" #: assets/templates/assets/system_user_detail.html:96 #: common/mixins/models.py:51 ops/models/adhoc.py:45 #: ops/templates/ops/adhoc_detail.html:90 ops/templates/ops/task_detail.html:64 -#: orgs/models.py:17 perms/models/base.py:55 +#: orders/templates/orders/login_confirm_order_detail.html:60 orgs/models.py:17 +#: perms/models/base.py:55 #: perms/templates/perms/asset_permission_detail.html:94 #: perms/templates/perms/remote_app_permission_detail.html:86 #: terminal/templates/terminal/terminal_detail.html:59 users/models/group.py:17 @@ -253,12 +254,14 @@ msgstr "创建日期" #: assets/templates/assets/domain_list.html:28 #: assets/templates/assets/system_user_detail.html:104 #: assets/templates/assets/system_user_list.html:55 ops/models/adhoc.py:43 -#: orgs/models.py:18 perms/models/base.py:56 +#: orders/serializers.py:23 +#: orders/templates/orders/login_confirm_order_detail.html:96 orgs/models.py:18 +#: perms/models/base.py:56 #: perms/templates/perms/asset_permission_detail.html:102 #: perms/templates/perms/remote_app_permission_detail.html:94 #: settings/models.py:34 terminal/models.py:33 #: terminal/templates/terminal/terminal_detail.html:63 users/models/group.py:15 -#: users/models/user.py:406 users/templates/users/user_detail.html:129 +#: users/models/user.py:408 users/templates/users/user_detail.html:129 #: users/templates/users/user_group_detail.html:67 #: users/templates/users/user_group_list.html:37 #: users/templates/users/user_profile.html:138 @@ -516,6 +519,7 @@ msgstr "创建远程应用" #: authentication/templates/authentication/_access_key_modal.html:34 #: ops/templates/ops/adhoc_history.html:59 ops/templates/ops/task_adhoc.html:64 #: ops/templates/ops/task_history.html:65 ops/templates/ops/task_list.html:18 +#: orders/templates/orders/login_confirm_order_list.html:19 #: perms/forms/asset_permission.py:21 #: perms/templates/perms/asset_permission_create_update.html:50 #: perms/templates/perms/asset_permission_list.html:56 @@ -699,12 +703,12 @@ msgstr "SSH网关,支持代理SSH,RDP和VNC" #: assets/templates/assets/system_user_list.html:48 audits/models.py:80 #: audits/templates/audits/login_log_list.html:57 authentication/forms.py:13 #: authentication/templates/authentication/login.html:65 -#: authentication/templates/authentication/new_login.html:92 +#: authentication/templates/authentication/xpack_login.html:92 #: ops/models/adhoc.py:189 perms/templates/perms/asset_permission_list.html:70 #: perms/templates/perms/asset_permission_user.html:55 #: perms/templates/perms/remote_app_permission_user.html:54 #: settings/templates/settings/_ldap_list_users_modal.html:30 users/forms.py:13 -#: users/models/user.py:371 users/templates/users/_select_user_modal.html:14 +#: users/models/user.py:373 users/templates/users/_select_user_modal.html:14 #: users/templates/users/user_detail.html:67 #: users/templates/users/user_list.html:36 #: users/templates/users/user_profile.html:47 @@ -729,7 +733,7 @@ msgstr "密码或密钥密码" #: assets/templates/assets/_asset_user_auth_view_modal.html:27 #: authentication/forms.py:15 #: authentication/templates/authentication/login.html:68 -#: authentication/templates/authentication/new_login.html:95 +#: authentication/templates/authentication/xpack_login.html:95 #: settings/forms.py:114 users/forms.py:15 users/forms.py:27 #: users/templates/users/reset_password.html:53 #: users/templates/users/user_password_authentication.html:18 @@ -744,7 +748,7 @@ msgstr "密码" #: assets/forms/user.py:30 assets/serializers/asset_user.py:71 #: assets/templates/assets/_asset_user_auth_update_modal.html:27 -#: users/models/user.py:400 +#: users/models/user.py:402 msgid "Private key" msgstr "ssh私钥" @@ -793,6 +797,8 @@ msgstr "使用逗号分隔多个命令,如: /bin/whoami,/sbin/ifconfig" #: assets/templates/assets/domain_gateway_list.html:68 #: assets/templates/assets/user_asset_list.html:76 #: audits/templates/audits/login_log_list.html:60 +#: orders/templates/orders/login_confirm_order_detail.html:33 +#: orders/templates/orders/login_confirm_order_list.html:15 #: perms/templates/perms/asset_permission_asset.html:58 settings/forms.py:144 #: users/templates/users/_granted_assets.html:31 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_asset_list.html:54 @@ -842,6 +848,7 @@ msgstr "系统平台" #: assets/models/asset.py:146 assets/models/authbook.py:27 #: assets/models/cmd_filter.py:22 assets/models/domain.py:54 #: assets/models/label.py:22 assets/templates/assets/asset_detail.html:110 +#: authentication/models.py:45 msgid "Is active" msgstr "激活" @@ -957,7 +964,7 @@ msgstr "带宽" msgid "Contact" msgstr "联系人" -#: assets/models/cluster.py:22 users/models/user.py:392 +#: assets/models/cluster.py:22 users/models/user.py:394 #: users/templates/users/user_detail.html:76 msgid "Phone" msgstr "手机" @@ -983,7 +990,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:512 +#: users/models/user.py:514 msgid "System" msgstr "系统" @@ -1011,7 +1018,7 @@ msgstr "BGP全网通" msgid "Regex" msgstr "正则表达式" -#: assets/models/cmd_filter.py:40 ops/models/command.py:21 +#: assets/models/cmd_filter.py:40 ops/models/command.py:22 #: ops/templates/ops/command_execution_list.html:64 terminal/models.py:163 #: terminal/templates/terminal/command_list.html:28 #: terminal/templates/terminal/command_list.html:68 @@ -1034,7 +1041,7 @@ msgstr "过滤器" #: assets/models/cmd_filter.py:51 #: assets/templates/assets/cmd_filter_rule_list.html:58 -#: audits/templates/audits/login_log_list.html:58 +#: audits/templates/audits/login_log_list.html:58 orders/models.py:41 #: perms/templates/perms/remote_app_permission_remote_app.html:54 #: settings/templates/settings/command_storage_create.html:31 #: settings/templates/settings/replay_storage_create.html:31 @@ -1097,8 +1104,11 @@ msgstr "默认资产组" #: audits/templates/audits/operate_log_list.html:72 #: audits/templates/audits/password_change_log_list.html:39 #: audits/templates/audits/password_change_log_list.html:56 -#: ops/templates/ops/command_execution_list.html:38 -#: ops/templates/ops/command_execution_list.html:63 +#: authentication/models.py:43 ops/templates/ops/command_execution_list.html:38 +#: ops/templates/ops/command_execution_list.html:63 orders/models.py:11 +#: orders/models.py:32 +#: orders/templates/orders/login_confirm_order_detail.html:32 +#: orders/templates/orders/login_confirm_order_list.html:14 #: perms/forms/asset_permission.py:78 perms/forms/remote_app_permission.py:34 #: perms/models/base.py:49 #: perms/templates/perms/asset_permission_create_update.html:41 @@ -1111,8 +1121,9 @@ msgstr "默认资产组" #: terminal/templates/terminal/command_list.html:65 #: terminal/templates/terminal/session_list.html:27 #: terminal/templates/terminal/session_list.html:71 users/forms.py:319 -#: users/models/user.py:127 users/models/user.py:143 users/models/user.py:500 -#: users/serializers/v1.py:132 users/templates/users/user_group_detail.html:78 +#: users/models/user.py:129 users/models/user.py:145 users/models/user.py:502 +#: users/serializers/group.py:21 +#: users/templates/users/user_group_detail.html:78 #: users/templates/users/user_group_list.html:36 users/views/user.py:250 #: xpack/plugins/orgs/forms.py:28 #: xpack/plugins/orgs/templates/orgs/org_detail.html:113 @@ -1235,7 +1246,7 @@ msgid "Reachable" msgstr "可连接" #: assets/models/utils.py:45 assets/tasks/const.py:86 -#: authentication/utils.py:13 xpack/plugins/license/models.py:78 +#: authentication/utils.py:16 xpack/plugins/license/models.py:78 msgid "Unknown" msgstr "未知" @@ -1266,7 +1277,7 @@ msgid "Backend" msgstr "后端" #: assets/serializers/asset_user.py:67 users/forms.py:262 -#: users/models/user.py:403 users/templates/users/first_login.html:42 +#: users/models/user.py:405 users/templates/users/first_login.html:42 #: users/templates/users/user_password_update.html:49 #: users/templates/users/user_profile.html:69 #: users/templates/users/user_profile_update.html:46 @@ -1470,6 +1481,7 @@ msgid "Asset user auth" msgstr "资产用户信息" #: assets/templates/assets/_asset_user_auth_view_modal.html:54 +#: authentication/templates/authentication/login_wait_confirm.html:117 msgid "Copy success" msgstr "复制成功" @@ -1490,6 +1502,7 @@ msgstr "关闭" #: audits/templates/audits/operate_log_list.html:77 #: audits/templates/audits/password_change_log_list.html:59 #: ops/templates/ops/task_adhoc.html:63 +#: orders/templates/orders/login_confirm_order_list.html:18 #: terminal/templates/terminal/command_list.html:33 #: terminal/templates/terminal/session_detail.html:50 msgid "Datetime" @@ -1768,7 +1781,7 @@ msgstr "硬盘" msgid "Date joined" msgstr "创建日期" -#: assets/templates/assets/asset_detail.html:148 authentication/models.py:15 +#: assets/templates/assets/asset_detail.html:148 authentication/models.py:19 #: authentication/templates/authentication/_access_key_modal.html:32 #: perms/models/base.py:51 #: perms/templates/perms/asset_permission_create_update.html:55 @@ -1787,6 +1800,7 @@ msgid "Refresh hardware" msgstr "更新硬件信息" #: assets/templates/assets/asset_detail.html:168 +#: authentication/templates/authentication/login_wait_confirm.html:42 msgid "Refresh" msgstr "刷新" @@ -2264,7 +2278,7 @@ msgstr "Agent" #: audits/models.py:85 audits/templates/audits/login_log_list.html:62 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 -#: users/forms.py:174 users/models/user.py:395 +#: users/forms.py:174 users/models/user.py:397 #: users/templates/users/first_login.html:45 msgid "MFA" msgstr "MFA" @@ -2278,6 +2292,8 @@ msgid "Reason" msgstr "原因" #: audits/models.py:87 audits/templates/audits/login_log_list.html:64 +#: orders/templates/orders/login_confirm_order_detail.html:35 +#: orders/templates/orders/login_confirm_order_list.html:17 #: xpack/plugins/cloud/models.py:275 xpack/plugins/cloud/models.py:310 #: xpack/plugins/cloud/templates/cloud/sync_instance_task_history.html:70 #: xpack/plugins/cloud/templates/cloud/sync_instance_task_instance.html:65 @@ -2338,6 +2354,8 @@ msgid "UA" msgstr "Agent" #: audits/templates/audits/login_log_list.html:61 +#: orders/templates/orders/login_confirm_order_detail.html:58 +#: orders/templates/orders/login_confirm_order_list.html:16 msgid "City" msgstr "城市" @@ -2348,23 +2366,23 @@ msgid "Date" msgstr "日期" #: audits/views.py:86 audits/views.py:130 audits/views.py:167 -#: audits/views.py:212 audits/views.py:244 templates/_nav.html:129 +#: audits/views.py:212 audits/views.py:244 templates/_nav.html:139 msgid "Audits" msgstr "日志审计" -#: audits/views.py:87 templates/_nav.html:133 +#: audits/views.py:87 templates/_nav.html:143 msgid "FTP log" msgstr "FTP日志" -#: audits/views.py:131 templates/_nav.html:134 +#: audits/views.py:131 templates/_nav.html:144 msgid "Operate log" msgstr "操作日志" -#: audits/views.py:168 templates/_nav.html:135 +#: audits/views.py:168 templates/_nav.html:145 msgid "Password change log" msgstr "改密日志" -#: audits/views.py:213 templates/_nav.html:132 +#: audits/views.py:213 templates/_nav.html:142 msgid "Login log" msgstr "登录日志" @@ -2372,25 +2390,33 @@ msgstr "登录日志" msgid "Command execution log" msgstr "命令执行" -#: authentication/api/auth.py:61 authentication/api/token.py:45 +#: authentication/api/auth.py:58 authentication/api/token.py:45 #: authentication/templates/authentication/login.html:52 -#: authentication/templates/authentication/new_login.html:77 +#: authentication/templates/authentication/xpack_login.html:77 msgid "Log in frequently and try again later" msgstr "登录频繁, 稍后重试" -#: authentication/api/auth.py:86 +#: authentication/api/auth.py:83 msgid "Please carry seed value and conduct MFA secondary certification" msgstr "请携带seed值, 进行MFA二次认证" -#: authentication/api/auth.py:176 +#: authentication/api/auth.py:173 msgid "Please verify the user name and password first" msgstr "请先进行用户名和密码验证" -#: authentication/api/auth.py:181 +#: authentication/api/auth.py:178 msgid "MFA certification failed" msgstr "MFA认证失败" -#: authentication/api/token.py:80 +#: authentication/api/auth.py:222 +msgid "No order found or order expired" +msgstr "没有找到工单,或者已过期" + +#: authentication/api/auth.py:228 +msgid "Order was rejected by {}" +msgstr "工单被拒绝 {}" + +#: authentication/api/token.py:81 msgid "MFA required" msgstr "" @@ -2491,10 +2517,38 @@ msgstr "账号已被锁定(请联系管理员解锁 或 {}分钟后重试)" msgid "MFA code" msgstr "MFA 验证码" -#: authentication/models.py:35 +#: authentication/models.py:39 msgid "Private Token" msgstr "ssh密钥" +#: authentication/models.py:43 +msgid "login_confirmation_setting" +msgstr "" + +#: authentication/models.py:44 +msgid "Reviewers" +msgstr "" + +#: authentication/models.py:44 +msgid "review_login_confirmation_settings" +msgstr "" + +#: authentication/models.py:53 +msgid "User login request: {}" +msgstr "用户登录请求: {}" + +#: authentication/models.py:57 +msgid "" +"User: {}\n" +"IP: {}\n" +"City: {}\n" +"Date: {}\n" +msgstr "" +"用户: {}\n" +"IP: {}\n" +"城市: {}\n" +"日期: {}\n" + #: authentication/templates/authentication/_access_key_modal.html:6 msgid "API key list" msgstr "API Key列表" @@ -2517,14 +2571,14 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: users/models/user.py:330 users/templates/users/user_profile.html:94 +#: users/models/user.py:332 users/templates/users/user_profile.html:94 #: users/templates/users/user_profile.html:163 #: users/templates/users/user_profile.html:166 msgid "Disable" msgstr "禁用" #: authentication/templates/authentication/_access_key_modal.html:67 -#: users/models/user.py:331 users/templates/users/user_profile.html:92 +#: users/models/user.py:333 users/templates/users/user_profile.html:92 #: users/templates/users/user_profile.html:170 msgid "Enable" msgstr "启用" @@ -2586,23 +2640,23 @@ msgstr "改变世界,从一点点开始。" #: authentication/templates/authentication/login.html:46 #: authentication/templates/authentication/login.html:73 -#: authentication/templates/authentication/new_login.html:101 +#: authentication/templates/authentication/xpack_login.html:101 #: templates/_header_bar.html:83 msgid "Login" msgstr "登录" #: authentication/templates/authentication/login.html:54 -#: authentication/templates/authentication/new_login.html:80 +#: authentication/templates/authentication/xpack_login.html:80 msgid "The user password has expired" msgstr "用户密码已过期" #: authentication/templates/authentication/login.html:57 -#: authentication/templates/authentication/new_login.html:83 +#: authentication/templates/authentication/xpack_login.html:83 msgid "Captcha invalid" msgstr "验证码错误" #: authentication/templates/authentication/login.html:84 -#: authentication/templates/authentication/new_login.html:105 +#: authentication/templates/authentication/xpack_login.html:105 #: users/templates/users/forgot_password.html:10 #: users/templates/users/forgot_password.html:25 msgid "Forgot password" @@ -2653,24 +2707,45 @@ msgstr "下一步" msgid "Can't provide security? Please contact the administrator!" msgstr "如果不能提供MFA验证码,请联系管理员!" -#: authentication/templates/authentication/new_login.html:67 +#: authentication/templates/authentication/login_wait_confirm.html:47 +msgid "Copy link" +msgstr "复制链接" + +#: authentication/templates/authentication/login_wait_confirm.html:52 +#: templates/flash_message_standalone.html:47 +msgid "Return" +msgstr "返回" + +#: authentication/templates/authentication/xpack_login.html:67 msgid "Welcome back, please enter username and password to login" msgstr "欢迎回来,请输入用户名和密码登录" -#: authentication/views/login.py:81 +#: authentication/views/login.py:82 msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: authentication/views/login.py:174 users/views/user.py:393 +#: authentication/views/login.py:156 users/views/user.py:393 #: users/views/user.py:418 msgid "MFA code invalid, or ntp sync server time" msgstr "MFA验证码不正确,或者服务器端时间不对" -#: authentication/views/login.py:205 +#: authentication/views/login.py:226 +msgid "" +"Wait for {} confirm, You also can copy link to her/him
    \n" +" Don't close this page" +msgstr "" +"等待 {} 确认, 你也可以复制链接发给他/她
    \n" +" 不要关闭本页面" + +#: authentication/views/login.py:231 +msgid "No order found" +msgstr "没有发现工单" + +#: authentication/views/login.py:254 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:206 +#: authentication/views/login.py:255 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" @@ -2757,6 +2832,21 @@ msgstr "" msgid "Websocket server run on port: {}, you should proxy it on nginx" msgstr "" +#: jumpserver/views.py:241 +#, fuzzy +#| msgid "" +#| "
    Luna is a separately deployed program, you need to deploy Luna, " +#| "koko, configure nginx for url distribution,
    If you see this " +#| "page, prove that you are not accessing the nginx listening port. Good " +#| "luck." +msgid "" +"
    Koko is a separately deployed program, you need to deploy Koko, " +"configure nginx for url distribution,
    If you see this page, " +"prove that you are not accessing the nginx listening port. Good luck." +msgstr "" +"
    Luna是单独部署的一个程序,你需要部署luna,koko,
    如果你看到了" +"这个页面,证明你访问的不是nginx监听的端口,祝你好运
    " + #: ops/api/celery.py:54 msgid "Waiting task start" msgstr "等待任务开始" @@ -2872,21 +2962,21 @@ msgstr "结果" msgid "Adhoc result summary" msgstr "汇总" -#: ops/models/command.py:22 +#: ops/models/command.py:23 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_list.html:56 #: xpack/plugins/cloud/models.py:273 msgid "Result" msgstr "结果" -#: ops/models/command.py:57 +#: ops/models/command.py:58 msgid "Task start" msgstr "任务开始" -#: ops/models/command.py:71 +#: ops/models/command.py:75 msgid "Command `{}` is forbidden ........" msgstr "命令 `{}` 不允许被执行 ......." -#: ops/models/command.py:77 +#: ops/models/command.py:81 msgid "Task end" msgstr "任务结束" @@ -3028,7 +3118,7 @@ msgstr "没有输入命令" msgid "No system user was selected" msgstr "没有选择系统用户" -#: ops/templates/ops/command_execution_create.html:296 +#: ops/templates/ops/command_execution_create.html:296 orders/models.py:26 msgid "Pending" msgstr "等待" @@ -3112,7 +3202,93 @@ msgstr "命令执行列表" msgid "Command execution" msgstr "命令执行" -#: orgs/mixins/models.py:58 orgs/mixins/serializers.py:26 orgs/models.py:31 +#: orders/models.py:12 orders/models.py:33 +#, fuzzy +#| msgid "User is inactive" +msgid "User display name" +msgstr "用户已禁用" + +#: orders/models.py:13 orders/models.py:36 +msgid "Body" +msgstr "" + +#: orders/models.py:24 +#, fuzzy +#| msgid "Accept" +msgid "Accepted" +msgstr "接受" + +#: orders/models.py:25 +msgid "Rejected" +msgstr "拒绝" + +#: orders/models.py:35 orders/templates/orders/login_confirm_order_list.html:13 +msgid "Title" +msgstr "标题" + +#: orders/models.py:37 +#: orders/templates/orders/login_confirm_order_detail.html:59 +msgid "Assignee" +msgstr "处理人" + +#: orders/models.py:38 +msgid "Assignee display name" +msgstr "处理人名称" + +#: orders/models.py:39 +#: orders/templates/orders/login_confirm_order_detail.html:34 +msgid "Assignees" +msgstr "待处理人" + +#: orders/models.py:40 +msgid "Assignees display name" +msgstr "待处理人名称" + +#: orders/serializers.py:21 +#: orders/templates/orders/login_confirm_order_detail.html:94 +#: orders/templates/orders/login_confirm_order_list.html:53 +#: terminal/templates/terminal/terminal_list.html:78 +msgid "Accept" +msgstr "接受" + +#: orders/serializers.py:22 +#: orders/templates/orders/login_confirm_order_detail.html:95 +#: orders/templates/orders/login_confirm_order_list.html:54 +#: terminal/templates/terminal/terminal_list.html:80 +msgid "Reject" +msgstr "拒绝" + +#: orders/serializers.py:43 +msgid "this order" +msgstr "这个工单" + +#: orders/templates/orders/login_confirm_order_detail.html:75 +msgid "ago" +msgstr "前" + +#: orders/templates/orders/login_confirm_order_list.html:83 +#: users/templates/users/user_list.html:327 +msgid "User is expired" +msgstr "用户已失效" + +#: orders/templates/orders/login_confirm_order_list.html:86 +#: users/templates/users/user_list.html:330 +msgid "User is inactive" +msgstr "用户已禁用" + +#: orders/views.py:15 orders/views.py:31 templates/_nav.html:127 +msgid "Orders" +msgstr "工单管理" + +#: orders/views.py:16 +msgid "Login confirm order list" +msgstr "登录复核工单列表" + +#: orders/views.py:32 +msgid "Login confirm order detail" +msgstr "登录复核工单详情" + +#: orgs/mixins/models.py:44 orgs/mixins/serializers.py:26 orgs/models.py:31 msgid "Organization" msgstr "组织" @@ -3136,7 +3312,7 @@ msgstr "提示:RDP 协议不支持单独控制上传或下载文件" #: perms/templates/perms/asset_permission_list.html:118 #: perms/templates/perms/remote_app_permission_list.html:16 #: templates/_nav.html:21 users/forms.py:293 users/models/group.py:26 -#: users/models/user.py:379 users/templates/users/_select_user_modal.html:16 +#: users/models/user.py:381 users/templates/users/_select_user_modal.html:16 #: users/templates/users/user_detail.html:218 #: users/templates/users/user_list.html:38 #: xpack/plugins/orgs/templates/orgs/org_list.html:16 @@ -3178,7 +3354,7 @@ msgstr "资产授权" #: perms/models/base.py:53 #: perms/templates/perms/asset_permission_detail.html:90 #: perms/templates/perms/remote_app_permission_detail.html:82 -#: users/models/user.py:411 users/templates/users/user_detail.html:107 +#: users/models/user.py:413 users/templates/users/user_detail.html:107 #: users/templates/users/user_profile.html:120 msgid "Date expired" msgstr "失效日期" @@ -3742,7 +3918,7 @@ msgid "Please submit the LDAP configuration before import" msgstr "请先提交LDAP配置再进行导入" #: settings/templates/settings/_ldap_list_users_modal.html:32 -#: users/models/user.py:375 users/templates/users/user_detail.html:71 +#: users/models/user.py:377 users/templates/users/user_detail.html:71 #: users/templates/users/user_profile.html:59 msgid "Email" msgstr "邮件" @@ -3946,7 +4122,7 @@ msgstr "用户来源不是LDAP" #: settings/views.py:19 settings/views.py:46 settings/views.py:73 #: settings/views.py:103 settings/views.py:131 settings/views.py:144 -#: settings/views.py:158 settings/views.py:185 templates/_nav.html:170 +#: settings/views.py:158 settings/views.py:185 templates/_nav.html:180 msgid "Settings" msgstr "系统设置" @@ -4139,7 +4315,7 @@ msgstr "终端管理" msgid "Job Center" msgstr "作业中心" -#: templates/_nav.html:116 templates/_nav.html:136 +#: templates/_nav.html:116 templates/_nav.html:146 msgid "Batch command" msgstr "批量命令" @@ -4147,15 +4323,19 @@ msgstr "批量命令" msgid "Task monitor" msgstr "任务监控" -#: templates/_nav.html:146 +#: templates/_nav.html:130 +msgid "Login confirm" +msgstr "登录复核" + +#: templates/_nav.html:156 msgid "XPack" msgstr "" -#: templates/_nav.html:154 xpack/plugins/cloud/views.py:28 +#: templates/_nav.html:164 xpack/plugins/cloud/views.py:28 msgid "Account list" msgstr "账户列表" -#: templates/_nav.html:155 +#: templates/_nav.html:165 msgid "Sync instance" msgstr "同步实例" @@ -4184,10 +4364,6 @@ msgstr "语言播放验证码" msgid "Captcha" msgstr "验证码" -#: templates/flash_message_standalone.html:47 -msgid "Return" -msgstr "返回" - #: templates/index.html:11 msgid "Total users" msgstr "用户总数" @@ -4496,14 +4672,6 @@ msgstr "地址" msgid "Alive" msgstr "在线" -#: terminal/templates/terminal/terminal_list.html:78 -msgid "Accept" -msgstr "接受" - -#: terminal/templates/terminal/terminal_list.html:80 -msgid "Reject" -msgstr "拒绝" - #: terminal/templates/terminal/terminal_modal_accept.html:5 msgid "Accept terminal registration" msgstr "接受终端注册" @@ -4541,7 +4709,7 @@ msgstr "你可以使用ssh客户端工具连接终端" msgid "Could not reset self otp, use profile reset instead" msgstr "不能再该页面重置MFA, 请去个人信息页面重置" -#: users/forms.py:32 users/models/user.py:383 +#: users/forms.py:32 users/models/user.py:385 #: users/templates/users/_select_user_modal.html:15 #: users/templates/users/user_detail.html:87 #: users/templates/users/user_list.html:37 @@ -4570,7 +4738,7 @@ msgstr "添加到用户组" msgid "Public key should not be the same as your old one." msgstr "不能和原来的密钥相同" -#: users/forms.py:90 users/forms.py:251 users/serializers/v1.py:116 +#: users/forms.py:90 users/forms.py:251 users/serializers/user.py:110 msgid "Not a valid ssh public key" msgstr "ssh密钥不合法" @@ -4655,98 +4823,98 @@ msgstr "复制你的公钥到这里" msgid "Select users" msgstr "选择用户" -#: users/models/user.py:50 users/templates/users/user_update.html:22 +#: users/models/user.py:52 users/templates/users/user_update.html:22 #: users/views/login.py:46 users/views/login.py:107 msgid "User auth from {}, go there change password" msgstr "用户认证源来自 {}, 请去相应系统修改密码" -#: users/models/user.py:126 users/models/user.py:508 +#: users/models/user.py:128 users/models/user.py:510 msgid "Administrator" msgstr "管理员" -#: users/models/user.py:128 +#: users/models/user.py:130 msgid "Application" msgstr "应用程序" -#: users/models/user.py:129 xpack/plugins/orgs/forms.py:30 +#: users/models/user.py:131 xpack/plugins/orgs/forms.py:30 #: xpack/plugins/orgs/templates/orgs/org_list.html:14 msgid "Auditor" msgstr "审计员" -#: users/models/user.py:139 +#: users/models/user.py:141 msgid "Org admin" msgstr "组织管理员" -#: users/models/user.py:141 +#: users/models/user.py:143 msgid "Org auditor" msgstr "组织审计员" -#: users/models/user.py:332 users/templates/users/user_profile.html:90 +#: users/models/user.py:334 users/templates/users/user_profile.html:90 msgid "Force enable" msgstr "强制启用" -#: users/models/user.py:386 +#: users/models/user.py:388 msgid "Avatar" msgstr "头像" -#: users/models/user.py:389 users/templates/users/user_detail.html:82 +#: users/models/user.py:391 users/templates/users/user_detail.html:82 msgid "Wechat" msgstr "微信" -#: users/models/user.py:418 users/templates/users/user_detail.html:103 +#: users/models/user.py:420 users/templates/users/user_detail.html:103 #: users/templates/users/user_list.html:39 #: users/templates/users/user_profile.html:102 msgid "Source" msgstr "用户来源" -#: users/models/user.py:422 +#: users/models/user.py:424 msgid "Date password last updated" msgstr "最后更新密码日期" -#: users/models/user.py:511 +#: users/models/user.py:513 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" -#: users/serializers/v1.py:45 +#: users/serializers/group.py:46 +msgid "Auditors cannot be join in the user group" +msgstr "审计员不能被加入到用户组" + +#: users/serializers/user.py:39 msgid "Groups name" msgstr "用户组名" -#: users/serializers/v1.py:46 +#: users/serializers/user.py:40 msgid "Source name" msgstr "用户来源名" -#: users/serializers/v1.py:47 +#: users/serializers/user.py:41 msgid "Is first login" msgstr "首次登录" -#: users/serializers/v1.py:48 +#: users/serializers/user.py:42 msgid "Role name" msgstr "角色名" -#: users/serializers/v1.py:49 +#: users/serializers/user.py:43 msgid "Is valid" msgstr "账户是否有效" -#: users/serializers/v1.py:50 +#: users/serializers/user.py:44 msgid "Is expired" msgstr " 是否过期" -#: users/serializers/v1.py:51 +#: users/serializers/user.py:45 msgid "Avatar url" msgstr "头像路径" -#: users/serializers/v1.py:72 +#: users/serializers/user.py:66 msgid "Role limit to {}" msgstr "角色只能为 {}" -#: users/serializers/v1.py:84 +#: users/serializers/user.py:78 msgid "Password does not match security rules" msgstr "密码不满足安全规则" -#: users/serializers/v1.py:157 -msgid "Auditors cannot be join in the user group" -msgstr "审计员不能被加入到用户组" - #: users/serializers_v2/user.py:36 msgid "name not unique" msgstr "名称重复" @@ -5092,14 +5260,6 @@ msgstr "删除" msgid "User Deleting failed." msgstr "用户删除失败" -#: users/templates/users/user_list.html:327 -msgid "User is expired" -msgstr "用户已失效" - -#: users/templates/users/user_list.html:330 -msgid "User is inactive" -msgstr "用户已禁用" - #: users/templates/users/user_otp_authentication.html:6 #: users/templates/users/user_password_authentication.html:6 msgid "Authenticate" @@ -6391,9 +6551,6 @@ msgstr "创建" #~ msgid "Start" #~ msgstr "开始" -#~ msgid "User login settings" -#~ msgstr "用户登录设置" - #~ msgid "Bit" #~ msgstr " 位" diff --git a/apps/orders/api.py b/apps/orders/api.py new file mode 100644 index 000000000..a588dd684 --- /dev/null +++ b/apps/orders/api.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +from rest_framework import viewsets, generics +from django.shortcuts import get_object_or_404 + +from common.permissions import IsValidUser +from common.mixins import CommonApiMixin +from . import serializers +from .models import LoginConfirmOrder + + +class LoginConfirmOrderViewSet(CommonApiMixin, viewsets.ModelViewSet): + serializer_class = serializers.LoginConfirmOrderSerializer + permission_classes = (IsValidUser,) + search_fields = ['user_display', 'title', 'ip', 'city'] + + def get_queryset(self): + queryset = LoginConfirmOrder.objects.all()\ + .filter(assignees=self.request.user) + return queryset + + +class LoginConfirmOrderCreateActionApi(generics.CreateAPIView): + permission_classes = (IsValidUser,) + serializer_class = serializers.LoginConfirmOrderActionSerializer + + def get_order(self): + order_id = self.kwargs.get('pk') + queryset = LoginConfirmOrder.objects.all()\ + .filter(assignees=self.request.user) + order = get_object_or_404(queryset, id=order_id) + return order + + def get_serializer_context(self): + context = super().get_serializer_context() + order = self.get_order() + context['order'] = order + return context diff --git a/apps/orders/apps.py b/apps/orders/apps.py index 384ab4368..3e58af6ea 100644 --- a/apps/orders/apps.py +++ b/apps/orders/apps.py @@ -3,3 +3,7 @@ from django.apps import AppConfig class OrdersConfig(AppConfig): name = 'orders' + + def ready(self): + from . import signals_handler + return super().ready() diff --git a/apps/orders/models.py b/apps/orders/models.py index b2614bd17..c574cacb9 100644 --- a/apps/orders/models.py +++ b/apps/orders/models.py @@ -3,31 +3,56 @@ from django.utils.translation import ugettext_lazy as _ from common.mixins.models import CommonModelMixin +__all__ = ['LoginConfirmOrder', 'Comment'] -class Order(CommonModelMixin): + +class Comment(CommonModelMixin): + order_id = models.UUIDField() + user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, verbose_name=_("User"), related_name='comments') + user_display = models.CharField(max_length=128, verbose_name=_("User display name")) + body = models.TextField(verbose_name=_("Body")) + + class Meta: + ordering = ('date_created', ) + + +class BaseOrder(CommonModelMixin): + STATUS_ACCEPTED = 'accepted' + STATUS_REJECTED = 'rejected' + STATUS_PENDING = 'pending' STATUS_CHOICES = ( - ('accepted', _("Accepted")), - ('rejected', _("Rejected")), - ('pending', _("Pending")) + (STATUS_ACCEPTED, _("Accepted")), + (STATUS_REJECTED, _("Rejected")), + (STATUS_PENDING, _("Pending")) ) - TYPE_LOGIN_REQUEST = 'login_request' + TYPE_LOGIN_CONFIRM = 'login_confirm' TYPE_CHOICES = ( - (TYPE_LOGIN_REQUEST, _("Login request")), + (TYPE_LOGIN_CONFIRM, 'Login confirm'), ) - user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='orders', verbose_name=_("User")) + user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='%(class)s_requested', verbose_name=_("User")) user_display = models.CharField(max_length=128, verbose_name=_("User display name")) title = models.CharField(max_length=256, verbose_name=_("Title")) body = models.TextField(verbose_name=_("Body")) - assignees = models.ManyToManyField('users.User', related_name='assign_orders', verbose_name=_("Assignees")) + assignee = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='%(class)s_handled', verbose_name=_("Assignee")) + assignee_display = models.CharField(max_length=128, blank=True, null=True, verbose_name=_("Assignee display name")) + assignees = models.ManyToManyField('users.User', related_name='%(class)s_assigned', verbose_name=_("Assignees")) assignees_display = models.CharField(max_length=128, verbose_name=_("Assignees display name"), blank=True) - - type = models.CharField(choices=TYPE_CHOICES, max_length=64) + type = models.CharField(choices=TYPE_CHOICES, max_length=16, verbose_name=_('Type')) status = models.CharField(choices=STATUS_CHOICES, max_length=16, default='pending') def __str__(self): return '{}: {}'.format(self.user_display, self.title) - class Meta: - ordering = ('date_created',) + @property + def comments(self): + return Comment.objects.filter(order_id=self.id) + class Meta: + abstract = True + ordering = ('-date_created',) + + +class LoginConfirmOrder(BaseOrder): + ip = models.GenericIPAddressField(blank=True, null=True) + city = models.CharField(max_length=16, blank=True, default='') diff --git a/apps/orders/serializers.py b/apps/orders/serializers.py new file mode 100644 index 000000000..d74e33208 --- /dev/null +++ b/apps/orders/serializers.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# +from rest_framework import serializers +from django.utils.translation import ugettext_lazy as _ + +from .models import LoginConfirmOrder, Comment + + +class LoginConfirmOrderSerializer(serializers.ModelSerializer): + class Meta: + model = LoginConfirmOrder + fields = [ + 'id', 'user', 'user_display', 'title', 'body', + 'ip', 'city', 'assignees', 'assignees_display', + 'type', 'status', 'date_created', 'date_updated', + ] + + +class LoginConfirmOrderActionSerializer(serializers.Serializer): + ACTION_CHOICES = ( + ('accept', _('Accept')), + ('reject', _('Reject')), + ('comment', _('Comment')) + ) + action = serializers.ChoiceField(choices=ACTION_CHOICES) + comment = serializers.CharField(allow_blank=True) + + def update(self, instance, validated_data): + pass + + def create_comments(self, order, user, validated_data): + comment_data = validated_data.get('comment') + action = validated_data.get('action') + comments_data = [] + if comment_data: + comments_data.append(comment_data) + Comment.objects.create( + order_id=order.id, body=comment_data, user=user, + user_display=str(user) + ) + if action != "comment": + action_display = dict(self.ACTION_CHOICES).get(action) + comment_data = '{} {} {}'.format(user, action_display, _("this order")) + comments_data.append(comment_data) + comments = [ + Comment(order_id=order.id, body=data, user=user, user_display=str(user)) + for data in comments_data + ] + Comment.objects.bulk_create(comments) + + @staticmethod + def perform_action(order, user, validated_data): + action = validated_data.get('action') + if action == "accept": + status = "accepted" + elif action == "reject": + status = "rejected" + else: + status = None + + if status: + order.status = status + order.assignee = user + order.assignee_display = str(user) + order.save() + + def create(self, validated_data): + order = self.context['order'] + user = self.context['request'].user + self.create_comments(order, user, validated_data) + self.perform_action(order, user, validated_data) + return validated_data diff --git a/apps/orders/signals_handler.py b/apps/orders/signals_handler.py new file mode 100644 index 000000000..9e2cdd2e7 --- /dev/null +++ b/apps/orders/signals_handler.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# +from django.utils.translation import ugettext as _ +from django.dispatch import receiver +from django.db.models.signals import m2m_changed +from django.conf import settings + +from common.tasks import send_mail_async +from common.utils import get_logger, reverse +from .models import LoginConfirmOrder + +logger = get_logger(__name__) + + +def send_mail(order, assignees): + recipient_list = [user.email for user in assignees] + user = order.user + if not recipient_list: + logger.error("Order not has assignees: {}".format(order.id)) + return + subject = '{}: {}'.format(_("New order"), order.title) + detail_url = reverse('orders:login-confirm-order-detail', + kwargs={'pk': order.id}, external=True) + message = _(""" +
    +

    Your has a new order

    +
    + Title: {order.title} +
    + User: {user} +
    + City: {order.city} +
    + IP: {order.ip} +
    + click here to review +
    +
    + """).format(order=order, user=user, url=detail_url) + if settings.DEBUG: + try: + print(message) + except OSError: + pass + + send_mail_async.delay(subject, message, recipient_list, html_message=message) + + +@receiver(m2m_changed, sender=LoginConfirmOrder.assignees.through) +def on_login_confirm_order_assignee_set(sender, instance=None, action=None, + model=None, pk_set=None, **kwargs): + print(">>>>>>>>>>>>>>>>>>>>>>>.") + print(action) + if action == 'post_add': + print("<<<<<<<<<<<<<<<<<<<<") + logger.debug('New order create, send mail: {}'.format(instance.id)) + assignees = model.objects.filter(pk__in=pk_set) + send_mail(instance, assignees) + diff --git a/apps/orders/templates/orders/login_confirm_order_detail.html b/apps/orders/templates/orders/login_confirm_order_detail.html new file mode 100644 index 000000000..55a86e009 --- /dev/null +++ b/apps/orders/templates/orders/login_confirm_order_detail.html @@ -0,0 +1,137 @@ +{% extends 'base.html' %} +{% load static %} +{% load i18n %} + +{% block content %} +
    +
    +
    +
    +
    +
    + {{ object.title }} +
    + +
    +
    +
    +
    +
    +
    +
    +
    {% trans 'User' %}:
    {{ object.user_display }}
    +
    {% trans 'IP' %}:
    {{ object.ip }}
    +
    {% trans 'Assignees' %}:
    {{ object.assignees_display }}
    +
    {% trans 'Status' %}:
    +
    + {% if object.status == "accpeted" %} + + {{ object.get_status_display }} + + {% endif %} + {% if object.status == "rejected" %} + + {{ object.get_status_display }} + + {% endif %} + {% if object.status == "pending" %} + + {{ object.get_status_display }} + + {% endif %} +
    +
    +
    +
    +
    +

    +
    {% trans 'City' %}:
    {{ object.city }}
    +
    {% trans 'Assignee' %}:
    {{ object.assignee_display | default_if_none:"" }}
    +
    {% trans 'Date created' %}:
    {{ object.date_created }}
    +
    +
    +
    +
    +
    +
    +
    +
    + {% for comment in object.comments %} +
    + + image + +
    + {{ comment.user_display }} {{ comment.date_created|timesince}} {% trans 'ago' %} +
    + {{ comment.date_created }} +
    + {{ comment.body }} +
    +
    +
    + {% endfor %} +
    +
    + + image + +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +{% endblock %} +{% block custom_foot_js %} + +{% endblock %} diff --git a/apps/orders/templates/orders/login_confirm_order_list.html b/apps/orders/templates/orders/login_confirm_order_list.html new file mode 100644 index 000000000..e21fb8c9f --- /dev/null +++ b/apps/orders/templates/orders/login_confirm_order_list.html @@ -0,0 +1,91 @@ +{% extends '_base_list.html' %} +{% load i18n static %} +{% block table_search %} + +{% endblock %} +{% block table_container %} + + + + + + + + + + + + + + + +
    + + {% trans 'Title' %}{% trans 'User' %}{% trans 'IP' %}{% trans 'City' %}{% trans 'Status' %}{% trans 'Datetime' %}{% trans 'Action' %}
    +{% endblock %} +{% block content_bottom_left %}{% endblock %} +{% block custom_foot_js %} + +{% endblock %} + diff --git a/apps/orders/urls/__init__.py b/apps/orders/urls/__init__.py new file mode 100644 index 000000000..ec51c5a2b --- /dev/null +++ b/apps/orders/urls/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# diff --git a/apps/orders/urls/api_urls.py b/apps/orders/urls/api_urls.py new file mode 100644 index 000000000..81828d3fe --- /dev/null +++ b/apps/orders/urls/api_urls.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# +from django.urls import path +from rest_framework.routers import DefaultRouter + +from .. import api + +app_name = 'orders' +router = DefaultRouter() + +router.register('login-confirm-orders', api.LoginConfirmOrderViewSet, 'login-confirm-order') + +urlpatterns = [ + path('login-confirm-order//actions/', + api.LoginConfirmOrderCreateActionApi.as_view(), + name='login-confirm-order-create-action' + ), +] + +urlpatterns += router.urls diff --git a/apps/orders/urls/views_urls.py b/apps/orders/urls/views_urls.py new file mode 100644 index 000000000..f4fe0ba05 --- /dev/null +++ b/apps/orders/urls/views_urls.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# +from django.urls import path +from .. import views + +app_name = 'orders' + +urlpatterns = [ + path('login-confirm-orders/', views.LoginConfirmOrderListView.as_view(), name='login-confirm-order-list'), + path('login-confirm-orders//', views.LoginConfirmOrderDetailView.as_view(), name='login-confirm-order-detail') +] diff --git a/apps/orders/utils.py b/apps/orders/utils.py new file mode 100644 index 000000000..ec51c5a2b --- /dev/null +++ b/apps/orders/utils.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# diff --git a/apps/orders/views.py b/apps/orders/views.py index 91ea44a21..93fb5abde 100644 --- a/apps/orders/views.py +++ b/apps/orders/views.py @@ -1,3 +1,34 @@ -from django.shortcuts import render +from django.views.generic import TemplateView, DetailView +from django.utils.translation import ugettext as _ -# Create your views here. +from common.permissions import PermissionsMixin, IsOrgAdmin +from .models import LoginConfirmOrder + + +class LoginConfirmOrderListView(PermissionsMixin, TemplateView): + template_name = 'orders/login_confirm_order_list.html' + permission_classes = (IsOrgAdmin,) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + 'app': _("Orders"), + 'action': _("Login confirm order list") + }) + return context + + +class LoginConfirmOrderDetailView(PermissionsMixin, DetailView): + template_name = 'orders/login_confirm_order_detail.html' + permission_classes = (IsOrgAdmin,) + + def get_queryset(self): + return LoginConfirmOrder.objects.filter(assignees=self.request.user) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + 'app': _("Orders"), + 'action': _("Login confirm order detail") + }) + return context diff --git a/apps/static/js/jumpserver.js b/apps/static/js/jumpserver.js index 33be3ba61..edea12c5b 100644 --- a/apps/static/js/jumpserver.js +++ b/apps/static/js/jumpserver.js @@ -307,7 +307,7 @@ function requestApi(props) { toastr.error(msg); } if (typeof props.error === 'function') { - return props.error(jqXHR.responseText, jqXHR.status); + return props.error(jqXHR.responseText, jqXHR.responseJSON, jqXHR.status); } }); // return true; diff --git a/apps/templates/_nav.html b/apps/templates/_nav.html index 5690c961d..36a2cb9ed 100644 --- a/apps/templates/_nav.html +++ b/apps/templates/_nav.html @@ -121,6 +121,16 @@ {% endif %} +{% if request.user.can_admin_current_org %} +
  • + + {% trans 'Orders' %} + + +
  • +{% endif %} {# Audits #} {% if request.user.can_admin_or_audit_current_org %} @@ -175,4 +185,4 @@ \ No newline at end of file + diff --git a/apps/users/utils.py b/apps/users/utils.py index 30868358c..9ab2c914e 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -193,7 +193,6 @@ def send_reset_ssh_key_mail(user): send_mail_async.delay(subject, message, recipient_list, html_message=message) - def get_user_or_tmp_user(request): user = request.user tmp_user = get_tmp_user_from_cache(request) @@ -212,8 +211,8 @@ def get_tmp_user_from_cache(request): return user -def set_tmp_user_to_cache(request, user): - cache.set(request.session.session_key+'user', user, 600) +def set_tmp_user_to_cache(request, user, ttl=3600): + cache.set(request.session.session_key+'user', user, ttl) def redirect_user_first_login_or_index(request, redirect_field_name): From fe235823b45b00e972704e3c68e5efa95fb2563d Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 30 Oct 2019 19:30:49 +0800 Subject: [PATCH 12/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9=E5=B7=A5?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/models.py | 2 +- apps/locale/zh/LC_MESSAGES/django.mo | Bin 81958 -> 82485 bytes apps/locale/zh/LC_MESSAGES/django.po | 61 ++++++++++--- apps/orders/api.py | 1 + .../orders/login_confirm_order_list.html | 16 +++- apps/static/css/jumpserver.css | 80 +++++++++++++++++ apps/static/js/jumpserver.js | 7 +- apps/templates/_filter_dropdown.html | 81 ++++++++++++++++++ 8 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 apps/templates/_filter_dropdown.html diff --git a/apps/authentication/models.py b/apps/authentication/models.py index bc92eb8b5..f50305651 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -50,7 +50,7 @@ class LoginConfirmSetting(CommonModelMixin): def create_confirm_order(self, request=None): from orders.models import LoginConfirmOrder - title = _('User login request: {}'.format(self.user)) + title = _('User login confirm: {}'.format(self.user)) if request: remote_addr = get_request_ip(request) city = get_ip_city(remote_addr) diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 6f724f936f9dee30ca50475f535c01280cfb31f7..fe18ec6df3287cda3353a310f4bd41666bc8eca2 100644 GIT binary patch delta 24361 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 diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index 0f18de91d..3d36fc15e 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: 2019-10-30 11:52+0800\n" +"POT-Creation-Date: 2019-10-30 14:51+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: ibuler \n" "Language-Team: Jumpserver team\n" @@ -2534,8 +2534,8 @@ msgid "review_login_confirmation_settings" msgstr "" #: authentication/models.py:53 -msgid "User login request: {}" -msgstr "用户登录请求: {}" +msgid "User login confirm: {}" +msgstr "用户登录复核: {}" #: authentication/models.py:57 msgid "" @@ -2833,18 +2833,12 @@ msgid "Websocket server run on port: {}, you should proxy it on nginx" msgstr "" #: jumpserver/views.py:241 -#, fuzzy -#| msgid "" -#| "
    Luna is a separately deployed program, you need to deploy Luna, " -#| "koko, configure nginx for url distribution,
    If you see this " -#| "page, prove that you are not accessing the nginx listening port. Good " -#| "luck." msgid "" "
    Koko is a separately deployed program, you need to deploy Koko, " "configure nginx for url distribution,
    If you see this page, " "prove that you are not accessing the nginx listening port. Good luck." msgstr "" -"
    Luna是单独部署的一个程序,你需要部署luna,koko,
    如果你看到了" +"
    Koko是单独部署的一个程序,你需要部署Koko, 并确保nginx配置转发,
    如果你看到了" "这个页面,证明你访问的不是nginx监听的端口,祝你好运
    " #: ops/api/celery.py:54 @@ -2853,7 +2847,7 @@ msgstr "等待任务开始" #: ops/api/command.py:35 msgid "Not has host {} permission" -msgstr "" +msgstr "没有该主机 {} 权限" #: ops/models/adhoc.py:38 msgid "Interval" @@ -3262,6 +3256,51 @@ msgstr "拒绝" msgid "this order" msgstr "这个工单" +#: orders/signals_handler.py:21 +#, fuzzy +#| msgid "New node" +msgid "New order" +msgstr "新节点" + +# msgid "Update user" +# msgstr "更新用户" +#: orders/signals_handler.py:24 +#, fuzzy, python-brace-format +msgid "" +"\n" +"
    \n" +"

    Your has a new order

    \n" +"
    \n" +" Title: {order.title}\n" +"
    \n" +" User: {user}\n" +"
    \n" +" City: {order.city}\n" +"
    \n" +" IP: {order.ip}\n" +"
    \n" +" click here to review \n" +"
    \n" +"
    \n" +" " +msgstr "" +"\n" +"
    \n" +"

    您有一个新工单

    \n" +"
    \n" +" 标题: {order.title}\n" +"
    \n" +" 用户: {user}\n" +"
    \n" +" 城市: {order.city}\n" +"
    \n" +" IP: {order.ip}\n" +"
    \n" +" 点我查看 \n" +"
    \n" +"
    \n" +" " + #: orders/templates/orders/login_confirm_order_detail.html:75 msgid "ago" msgstr "前" diff --git a/apps/orders/api.py b/apps/orders/api.py index a588dd684..aec04ad46 100644 --- a/apps/orders/api.py +++ b/apps/orders/api.py @@ -12,6 +12,7 @@ from .models import LoginConfirmOrder class LoginConfirmOrderViewSet(CommonApiMixin, viewsets.ModelViewSet): serializer_class = serializers.LoginConfirmOrderSerializer permission_classes = (IsValidUser,) + filter_fields = ['status', 'title'] search_fields = ['user_display', 'title', 'ip', 'city'] def get_queryset(self): diff --git a/apps/orders/templates/orders/login_confirm_order_list.html b/apps/orders/templates/orders/login_confirm_order_list.html index e21fb8c9f..e7b8da90c 100644 --- a/apps/orders/templates/orders/login_confirm_order_list.html +++ b/apps/orders/templates/orders/login_confirm_order_list.html @@ -1,7 +1,8 @@ {% extends '_base_list.html' %} {% load i18n static %} {% block table_search %} - +{% endblock %} +{% block custom_head_css_js %} {% endblock %} {% block table_container %} @@ -22,6 +23,7 @@
    +{% include '_filter_dropdown.html' %} {% endblock %} {% block content_bottom_left %}{% endblock %} {% block custom_foot_js %} @@ -30,6 +32,7 @@ var orderTable = 0; function initTable() { var options = { ele: $('#login_confirm_order_list_table'), + oSearch: {sSearch: "status:pending"}, columnDefs: [ {targets: 1, createdCell: function (td, cellData, rowData) { cellData = htmlEscape(cellData); @@ -79,6 +82,17 @@ function initTable() { $(document).ready(function(){ initTable(); + $('') + var menu = [ + {title: "IP", value: "ip"}, + {title: "{% trans 'Title' %}", value: "title"}, + {title: "{% trans 'Status' %}", value: "status", submenu: [ + {title: "{% trans 'Pending' %}", value: "pending"}, + {title: "{% trans 'Accepted' %}", value: "accepted"}, + {title: "{% trans 'Rejected' %}", value: "rejected"} + ]} + ]; + initTableFilterDropdown('#login_confirm_order_list_table_filter input', menu) }).on('click', '.expired', function () { var msg = '{% trans "User is expired" %}'; toastr.error(msg) diff --git a/apps/static/css/jumpserver.css b/apps/static/css/jumpserver.css index 02e738d24..ffcb87d33 100644 --- a/apps/static/css/jumpserver.css +++ b/apps/static/css/jumpserver.css @@ -474,3 +474,83 @@ span.select2-selection__placeholder { .p-r-5 { padding-right: 5px; } + +.dropdown-submenu { + position: relative; +} + +.dropdown-submenu>.dropdown-menu { + top: 0; + left: 100%; + margin-top: -6px; + margin-left: -1px; + -webkit-border-radius: 0 6px 6px 6px; + -moz-border-radius: 0 6px 6px; + border-radius: 0 6px 6px 6px; +} + +.dropdown-submenu:hover>.dropdown-menu { + display: block; +} + +.dropdown-submenu>a:after { + display: block; + content: " "; + float: right; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + border-width: 5px 0 5px 5px; + border-left-color: #ccc; + margin-top: 5px; + margin-right: -10px; +} + +.dropdown-submenu:hover>a:after { + border-left-color: #fff; +} + +.dropdown-submenu.pull-left { + float: none; +} + +.dropdown-submenu.pull-left>.dropdown-menu { + left: -100px; + margin-left: 10px; + -webkit-border-radius: 6px 0 6px 6px; + -moz-border-radius: 6px 0 6px 6px; + border-radius: 6px 0 6px 6px; +} + + +.bootstrap-tagsinput { + border: 1px solid #e5e6e7; + box-shadow: none; + padding: 4px 6px; + cursor: text; +} + +/*.bootstrap-tagsinput {*/ +/* background-color: #fff;*/ +/* border: 1px solid #ccc;*/ +/* box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);*/ +/* display: inline-block;*/ +/* color: #555;*/ +/* vertical-align: middle;*/ +/* border-radius: 4px;*/ +/* max-width: 100%;*/ +/* line-height: 22px;*/ +/*}*/ + +.bootstrap-tagsinput input { + border: none; + box-shadow: none; + outline: none; + background-color: transparent; + padding: 0 6px; + margin: 0; + width: auto; + height: 22px; + max-width: inherit; +} diff --git a/apps/static/js/jumpserver.js b/apps/static/js/jumpserver.js index edea12c5b..89ac43364 100644 --- a/apps/static/js/jumpserver.js +++ b/apps/static/js/jumpserver.js @@ -611,16 +611,21 @@ jumpserver.initServerSideDataTable = function (options) { style: select_style, selector: 'td:first-child' }; + var dom = '<"#uc.pull-left"> <"pull-right"<"inline"l> <"#fb.inline"> <"inline"f><"#fa.inline">>' + + 'tr' + + '<"row m-t"<"col-md-8"<"#op.col-md-6"><"col-md-6 text-center"i>><"col-md-4"p>>'; var table = ele.DataTable({ pageLength: options.pageLength || 15, // dom: options.dom || '<"#uc.pull-left">fltr<"row m-t"<"col-md-8"<"#op.col-md-6"><"col-md-6 text-center"i>><"col-md-4"p>>', - dom: options.dom || '<"#uc.pull-left"><"pull-right"<"inline"l><"#fb.inline"><"inline"f><"#fa.inline">>tr<"row m-t"<"col-md-8"<"#op.col-md-6"><"col-md-6 text-center"i>><"col-md-4"p>>', + // dom: options.dom || '<"#uc.pull-left"><"pull-right"<"inline"l><"#fb.inline"><"inline"<"table-filter"f>><"#fa.inline">>tr<"row m-t"<"col-md-8"<"#op.col-md-6"><"col-md-6 text-center"i>><"col-md-4"p>>', + dom: dom, order: options.order || [], buttons: [], columnDefs: columnDefs, serverSide: true, processing: true, searchDelay: 800, + oSearch: options.oSearch, ajax: { url: options.ajax_url, error: function (jqXHR, textStatus, errorThrown) { diff --git a/apps/templates/_filter_dropdown.html b/apps/templates/_filter_dropdown.html new file mode 100644 index 000000000..e99ca79ef --- /dev/null +++ b/apps/templates/_filter_dropdown.html @@ -0,0 +1,81 @@ + + + From dc3a9561c293f55b796730282b127369db0b46c3 Mon Sep 17 00:00:00 2001 From: ibuler Date: Thu, 31 Oct 2019 18:23:43 +0800 Subject: [PATCH 13/55] =?UTF-8?q?[Update]=20=E5=9F=BA=E6=9C=AC=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E7=99=BB=E9=99=86=E5=AE=A1=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/api/__init__.py | 1 + apps/authentication/api/login_confirm.py | 25 ++ .../migrations/0003_loginconfirmsetting.py | 32 ++ apps/authentication/models.py | 8 +- apps/authentication/serializers.py | 11 +- .../authentication/login_wait_confirm.html | 3 - apps/authentication/urls/api_urls.py | 3 +- apps/authentication/views/login.py | 2 +- apps/locale/zh/LC_MESSAGES/django.mo | Bin 82485 -> 84548 bytes apps/locale/zh/LC_MESSAGES/django.po | 287 ++++++++++-------- apps/orders/migrations/0001_initial.py | 59 ++++ apps/orders/models.py | 8 + apps/orders/signals_handler.py | 61 ++-- .../orders/login_confirm_order_list.html | 47 +-- apps/orders/utils.py | 60 ++++ apps/static/js/jumpserver.js | 3 + apps/templates/flash_message_standalone.html | 3 - apps/users/models/user.py | 7 + apps/users/templates/users/user_detail.html | 154 +++++++--- 19 files changed, 532 insertions(+), 242 deletions(-) create mode 100644 apps/authentication/api/login_confirm.py create mode 100644 apps/authentication/migrations/0003_loginconfirmsetting.py create mode 100644 apps/orders/migrations/0001_initial.py diff --git a/apps/authentication/api/__init__.py b/apps/authentication/api/__init__.py index c2a4a740f..4f6475124 100644 --- a/apps/authentication/api/__init__.py +++ b/apps/authentication/api/__init__.py @@ -5,3 +5,4 @@ from .auth import * from .token import * from .mfa import * from .access_key import * +from .login_confirm import * diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py new file mode 100644 index 000000000..3ce26f84d --- /dev/null +++ b/apps/authentication/api/login_confirm.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# +from rest_framework.generics import UpdateAPIView +from django.shortcuts import get_object_or_404 + +from common.permissions import IsOrgAdmin +from ..models import LoginConfirmSetting +from ..serializers import LoginConfirmSettingSerializer + +__all__ = ['LoginConfirmSettingUpdateApi'] + + +class LoginConfirmSettingUpdateApi(UpdateAPIView): + permission_classes = (IsOrgAdmin,) + serializer_class = LoginConfirmSettingSerializer + + def get_object(self): + from users.models import User + user_id = self.kwargs.get('user_id') + user = get_object_or_404(User, pk=user_id) + defaults = {'user': user} + s, created = LoginConfirmSetting.objects.get_or_create( + defaults, user=user, + ) + return s diff --git a/apps/authentication/migrations/0003_loginconfirmsetting.py b/apps/authentication/migrations/0003_loginconfirmsetting.py new file mode 100644 index 000000000..c8043bc87 --- /dev/null +++ b/apps/authentication/migrations/0003_loginconfirmsetting.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.5 on 2019-10-31 10:23 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('authentication', '0002_auto_20190729_1423'), + ] + + operations = [ + migrations.CreateModel( + name='LoginConfirmSetting', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('is_active', models.BooleanField(default=True, verbose_name='Is active')), + ('reviewers', models.ManyToManyField(blank=True, related_name='review_login_confirm_settings', to=settings.AUTH_USER_MODEL, verbose_name='Reviewers')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='login_confirm_setting', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/authentication/models.py b/apps/authentication/models.py index f50305651..4f0e06fb6 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -1,7 +1,7 @@ import uuid from django.db import models from django.utils import timezone -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ugettext_lazy as _, ugettext as __ from rest_framework.authtoken.models import Token from django.conf import settings @@ -40,8 +40,8 @@ class PrivateToken(Token): class LoginConfirmSetting(CommonModelMixin): - user = models.OneToOneField('users.User', on_delete=models.CASCADE, verbose_name=_("User"), related_name=_("login_confirmation_setting")) - reviewers = models.ManyToManyField('users.User', verbose_name=_("Reviewers"), related_name=_("review_login_confirmation_settings")) + user = models.OneToOneField('users.User', on_delete=models.CASCADE, verbose_name=_("User"), related_name="login_confirm_setting") + reviewers = models.ManyToManyField('users.User', verbose_name=_("Reviewers"), related_name="review_login_confirm_settings", blank=True) is_active = models.BooleanField(default=True, verbose_name=_("Is active")) @classmethod @@ -50,7 +50,7 @@ class LoginConfirmSetting(CommonModelMixin): def create_confirm_order(self, request=None): from orders.models import LoginConfirmOrder - title = _('User login confirm: {}'.format(self.user)) + title = _('User login confirm: {}').format(self.user) if request: remote_addr = get_request_ip(request) city = get_ip_city(remote_addr) diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index 584da768f..7463d30ca 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -4,17 +4,16 @@ from django.core.cache import cache from rest_framework import serializers from users.models import User -from .models import AccessKey +from .models import AccessKey, LoginConfirmSetting __all__ = [ 'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer', - 'MFAChallengeSerializer', + 'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', ] class AccessKeySerializer(serializers.ModelSerializer): - class Meta: model = AccessKey fields = ['id', 'secret', 'is_active', 'date_created'] @@ -87,3 +86,9 @@ class MFAChallengeSerializer(BearerTokenMixin, serializers.Serializer): username = self.context["username"] return self.create_response(username) + +class LoginConfirmSettingSerializer(serializers.ModelSerializer): + class Meta: + model = LoginConfirmSetting + fields = ['id', 'user', 'reviewers', 'date_created', 'date_updated'] + read_only_fields = ['date_created', 'date_updated'] diff --git a/apps/authentication/templates/authentication/login_wait_confirm.html b/apps/authentication/templates/authentication/login_wait_confirm.html index 0a14e8515..0167236df 100644 --- a/apps/authentication/templates/authentication/login_wait_confirm.html +++ b/apps/authentication/templates/authentication/login_wait_confirm.html @@ -61,9 +61,6 @@
    {% include '_copyright.html' %}
    -
    - 2014-2019 -
    diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index a90b328cc..b47e5eb72 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -19,7 +19,8 @@ urlpatterns = [ api.UserConnectionTokenApi.as_view(), name='connection-token'), path('otp/auth/', api.UserOtpAuthApi.as_view(), name='user-otp-auth'), path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'), - path('order/auth/', api.UserOrderAcceptAuthApi.as_view(), name='user-order-auth') + path('order/auth/', api.UserOrderAcceptAuthApi.as_view(), name='user-order-auth'), + path('login-confirm-settings//', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update') ] urlpatterns += router.urls diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 646268eea..761e656a1 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -179,7 +179,7 @@ class UserLoginGuardView(RedirectView): if user.otp_enabled and user.otp_secret_key and \ not self.request.session.get('auth_otp'): return reverse('authentication:login-otp') - confirm_setting = LoginConfirmSetting.get_user_confirm_setting(user) + confirm_setting = user.get_login_confirm_setting() if confirm_setting and not self.request.session.get('auth_confirm'): order = confirm_setting.create_confirm_order(self.request) self.request.session['auth_order_id'] = str(order.id) diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index fe18ec6df3287cda3353a310f4bd41666bc8eca2..7d21e0d682223b8118cb8487069700ed5d42e1ea 100644 GIT binary patch delta 25414 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%0D1VrV_$3hUp@^P-O~_1c30fQ8F5Qj$1nQ$e?JA` znb>Lkf^lwxu{*xFzG6e{!X>e*ru$lXh|RJ8oqR2*?o{H*a73s@<{Er^K(_vLm)kK<1D%@$hm!2biN&e!Px delta 24277 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\n" "Language-Team: Jumpserver team\n" @@ -83,7 +83,7 @@ msgstr "运行参数" #: assets/templates/assets/domain_detail.html:60 #: assets/templates/assets/domain_list.html:26 #: assets/templates/assets/label_list.html:16 -#: assets/templates/assets/system_user_list.html:51 audits/models.py:19 +#: assets/templates/assets/system_user_list.html:51 audits/models.py:20 #: audits/templates/audits/ftp_log_list.html:44 #: audits/templates/audits/ftp_log_list.html:74 #: perms/forms/asset_permission.py:84 perms/models/asset_permission.py:80 @@ -144,7 +144,7 @@ msgstr "资产" #: settings/templates/settings/terminal_setting.html:105 terminal/models.py:23 #: terminal/models.py:260 terminal/templates/terminal/terminal_detail.html:43 #: terminal/templates/terminal/terminal_list.html:29 users/models/group.py:14 -#: users/models/user.py:375 users/templates/users/_select_user_modal.html:13 +#: users/models/user.py:382 users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_detail.html:63 #: users/templates/users/user_group_detail.html:55 #: users/templates/users/user_group_list.html:35 @@ -197,7 +197,7 @@ msgstr "参数" #: orgs/models.py:16 perms/models/base.py:54 #: perms/templates/perms/asset_permission_detail.html:98 #: perms/templates/perms/remote_app_permission_detail.html:90 -#: users/models/user.py:416 users/serializers/group.py:32 +#: users/models/user.py:423 users/serializers/group.py:32 #: users/templates/users/user_detail.html:111 #: xpack/plugins/change_auth_plan/models.py:108 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:113 @@ -261,7 +261,7 @@ msgstr "创建日期" #: perms/templates/perms/remote_app_permission_detail.html:94 #: settings/models.py:34 terminal/models.py:33 #: terminal/templates/terminal/terminal_detail.html:63 users/models/group.py:15 -#: users/models/user.py:408 users/templates/users/user_detail.html:129 +#: users/models/user.py:415 users/templates/users/user_detail.html:129 #: users/templates/users/user_group_detail.html:67 #: users/templates/users/user_group_list.html:37 #: users/templates/users/user_profile.html:138 @@ -413,7 +413,7 @@ msgstr "详情" #: assets/templates/assets/label_list.html:39 #: assets/templates/assets/system_user_detail.html:26 #: assets/templates/assets/system_user_list.html:29 -#: assets/templates/assets/system_user_list.html:81 audits/models.py:33 +#: assets/templates/assets/system_user_list.html:81 audits/models.py:34 #: perms/templates/perms/asset_permission_detail.html:30 #: perms/templates/perms/asset_permission_list.html:178 #: perms/templates/perms/remote_app_permission_detail.html:30 @@ -457,7 +457,7 @@ msgstr "更新" #: assets/templates/assets/domain_list.html:55 #: assets/templates/assets/label_list.html:40 #: assets/templates/assets/system_user_detail.html:30 -#: assets/templates/assets/system_user_list.html:82 audits/models.py:34 +#: assets/templates/assets/system_user_list.html:82 audits/models.py:35 #: authentication/templates/authentication/_access_key_modal.html:65 #: ops/templates/ops/task_list.html:69 #: perms/templates/perms/asset_permission_detail.html:34 @@ -513,7 +513,7 @@ msgstr "创建远程应用" #: assets/templates/assets/domain_gateway_list.html:73 #: assets/templates/assets/domain_list.html:29 #: assets/templates/assets/label_list.html:17 -#: assets/templates/assets/system_user_list.html:56 audits/models.py:38 +#: assets/templates/assets/system_user_list.html:56 audits/models.py:39 #: audits/templates/audits/operate_log_list.html:47 #: audits/templates/audits/operate_log_list.html:73 #: authentication/templates/authentication/_access_key_modal.html:34 @@ -700,7 +700,7 @@ msgstr "SSH网关,支持代理SSH,RDP和VNC" #: assets/templates/assets/admin_user_list.html:45 #: assets/templates/assets/domain_gateway_list.html:71 #: assets/templates/assets/system_user_detail.html:62 -#: assets/templates/assets/system_user_list.html:48 audits/models.py:80 +#: assets/templates/assets/system_user_list.html:48 audits/models.py:81 #: audits/templates/audits/login_log_list.html:57 authentication/forms.py:13 #: authentication/templates/authentication/login.html:65 #: authentication/templates/authentication/xpack_login.html:92 @@ -708,7 +708,7 @@ msgstr "SSH网关,支持代理SSH,RDP和VNC" #: perms/templates/perms/asset_permission_user.html:55 #: perms/templates/perms/remote_app_permission_user.html:54 #: settings/templates/settings/_ldap_list_users_modal.html:30 users/forms.py:13 -#: users/models/user.py:373 users/templates/users/_select_user_modal.html:14 +#: users/models/user.py:380 users/templates/users/_select_user_modal.html:14 #: users/templates/users/user_detail.html:67 #: users/templates/users/user_list.html:36 #: users/templates/users/user_profile.html:47 @@ -748,7 +748,7 @@ msgstr "密码" #: assets/forms/user.py:30 assets/serializers/asset_user.py:71 #: assets/templates/assets/_asset_user_auth_update_modal.html:27 -#: users/models/user.py:402 +#: users/models/user.py:409 msgid "Private key" msgstr "ssh私钥" @@ -798,7 +798,7 @@ msgstr "使用逗号分隔多个命令,如: /bin/whoami,/sbin/ifconfig" #: assets/templates/assets/user_asset_list.html:76 #: audits/templates/audits/login_log_list.html:60 #: orders/templates/orders/login_confirm_order_detail.html:33 -#: orders/templates/orders/login_confirm_order_list.html:15 +#: orders/templates/orders/login_confirm_order_list.html:16 #: perms/templates/perms/asset_permission_asset.html:58 settings/forms.py:144 #: users/templates/users/_granted_assets.html:31 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_asset_list.html:54 @@ -964,7 +964,7 @@ msgstr "带宽" msgid "Contact" msgstr "联系人" -#: assets/models/cluster.py:22 users/models/user.py:394 +#: assets/models/cluster.py:22 users/models/user.py:401 #: users/templates/users/user_detail.html:76 msgid "Phone" msgstr "手机" @@ -990,7 +990,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:514 +#: users/models/user.py:521 msgid "System" msgstr "系统" @@ -1097,8 +1097,8 @@ msgstr "资产组" msgid "Default asset group" msgstr "默认资产组" -#: assets/models/label.py:15 audits/models.py:17 audits/models.py:37 -#: audits/models.py:50 audits/templates/audits/ftp_log_list.html:36 +#: assets/models/label.py:15 audits/models.py:18 audits/models.py:38 +#: audits/models.py:51 audits/templates/audits/ftp_log_list.html:36 #: audits/templates/audits/ftp_log_list.html:73 #: audits/templates/audits/operate_log_list.html:39 #: audits/templates/audits/operate_log_list.html:72 @@ -1108,7 +1108,7 @@ msgstr "默认资产组" #: ops/templates/ops/command_execution_list.html:63 orders/models.py:11 #: orders/models.py:32 #: orders/templates/orders/login_confirm_order_detail.html:32 -#: orders/templates/orders/login_confirm_order_list.html:14 +#: orders/templates/orders/login_confirm_order_list.html:15 #: perms/forms/asset_permission.py:78 perms/forms/remote_app_permission.py:34 #: perms/models/base.py:49 #: perms/templates/perms/asset_permission_create_update.html:41 @@ -1121,7 +1121,7 @@ msgstr "默认资产组" #: terminal/templates/terminal/command_list.html:65 #: terminal/templates/terminal/session_list.html:27 #: terminal/templates/terminal/session_list.html:71 users/forms.py:319 -#: users/models/user.py:129 users/models/user.py:145 users/models/user.py:502 +#: users/models/user.py:136 users/models/user.py:152 users/models/user.py:509 #: users/serializers/group.py:21 #: users/templates/users/user_group_detail.html:78 #: users/templates/users/user_group_list.html:36 users/views/user.py:250 @@ -1211,7 +1211,7 @@ msgid "Login mode" msgstr "登录模式" #: assets/models/user.py:166 assets/templates/assets/user_asset_list.html:79 -#: audits/models.py:20 audits/templates/audits/ftp_log_list.html:52 +#: audits/models.py:21 audits/templates/audits/ftp_log_list.html:52 #: audits/templates/audits/ftp_log_list.html:75 #: perms/forms/asset_permission.py:90 perms/forms/remote_app_permission.py:43 #: perms/models/asset_permission.py:82 perms/models/remote_app_permission.py:16 @@ -1277,7 +1277,7 @@ msgid "Backend" msgstr "后端" #: assets/serializers/asset_user.py:67 users/forms.py:262 -#: users/models/user.py:405 users/templates/users/first_login.html:42 +#: users/models/user.py:412 users/templates/users/first_login.html:42 #: users/templates/users/user_password_update.html:49 #: users/templates/users/user_profile.html:69 #: users/templates/users/user_profile_update.html:46 @@ -1470,8 +1470,8 @@ msgstr "请输入密码" #: assets/templates/assets/_asset_user_auth_update_modal.html:68 #: assets/templates/assets/asset_detail.html:302 -#: users/templates/users/user_detail.html:313 -#: users/templates/users/user_detail.html:340 +#: users/templates/users/user_detail.html:364 +#: users/templates/users/user_detail.html:391 #: xpack/plugins/interface/views.py:35 msgid "Update successfully!" msgstr "更新成功" @@ -1668,10 +1668,11 @@ msgstr "选择节点" #: authentication/templates/authentication/_mfa_confirm_modal.html:20 #: settings/templates/settings/terminal_setting.html:168 #: templates/_modal.html:23 terminal/templates/terminal/session_detail.html:112 -#: users/templates/users/user_detail.html:394 -#: users/templates/users/user_detail.html:420 -#: users/templates/users/user_detail.html:443 -#: users/templates/users/user_detail.html:488 +#: users/templates/users/user_detail.html:271 +#: users/templates/users/user_detail.html:445 +#: users/templates/users/user_detail.html:471 +#: users/templates/users/user_detail.html:494 +#: users/templates/users/user_detail.html:539 #: users/templates/users/user_group_create_update.html:32 #: users/templates/users/user_group_list.html:120 #: users/templates/users/user_list.html:256 @@ -1871,9 +1872,9 @@ msgstr "显示所有子节点资产" #: assets/templates/assets/asset_list.html:417 #: assets/templates/assets/system_user_list.html:129 -#: users/templates/users/user_detail.html:388 -#: users/templates/users/user_detail.html:414 -#: users/templates/users/user_detail.html:482 +#: users/templates/users/user_detail.html:439 +#: users/templates/users/user_detail.html:465 +#: users/templates/users/user_detail.html:533 #: users/templates/users/user_group_list.html:114 #: users/templates/users/user_list.html:250 #: xpack/plugins/interface/templates/interface/interface.html:97 @@ -1887,9 +1888,9 @@ msgstr "删除选择资产" #: assets/templates/assets/asset_list.html:421 #: assets/templates/assets/system_user_list.html:133 #: settings/templates/settings/terminal_setting.html:166 -#: users/templates/users/user_detail.html:392 -#: users/templates/users/user_detail.html:418 -#: users/templates/users/user_detail.html:486 +#: users/templates/users/user_detail.html:443 +#: users/templates/users/user_detail.html:469 +#: users/templates/users/user_detail.html:537 #: users/templates/users/user_group_list.html:118 #: users/templates/users/user_list.html:254 #: xpack/plugins/interface/templates/interface/interface.html:101 @@ -2194,7 +2195,7 @@ msgstr "资产管理" msgid "System user asset" msgstr "系统用户资产" -#: audits/models.py:18 audits/models.py:41 audits/models.py:52 +#: audits/models.py:19 audits/models.py:42 audits/models.py:53 #: audits/templates/audits/ftp_log_list.html:76 #: audits/templates/audits/operate_log_list.html:76 #: audits/templates/audits/password_change_log_list.html:58 @@ -2204,86 +2205,86 @@ msgstr "系统用户资产" msgid "Remote addr" msgstr "远端地址" -#: audits/models.py:21 audits/templates/audits/ftp_log_list.html:77 +#: audits/models.py:22 audits/templates/audits/ftp_log_list.html:77 msgid "Operate" msgstr "操作" -#: audits/models.py:22 audits/templates/audits/ftp_log_list.html:59 +#: audits/models.py:23 audits/templates/audits/ftp_log_list.html:59 #: audits/templates/audits/ftp_log_list.html:78 msgid "Filename" msgstr "文件名" -#: audits/models.py:23 audits/models.py:76 +#: audits/models.py:24 audits/models.py:77 #: audits/templates/audits/ftp_log_list.html:79 #: ops/templates/ops/command_execution_list.html:68 #: ops/templates/ops/task_list.html:15 -#: users/templates/users/user_detail.html:464 +#: users/templates/users/user_detail.html:515 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:14 #: xpack/plugins/cloud/api.py:61 msgid "Success" msgstr "成功" -#: audits/models.py:32 +#: audits/models.py:33 #: authentication/templates/authentication/_access_key_modal.html:22 #: xpack/plugins/vault/templates/vault/vault.html:46 msgid "Create" msgstr "创建" -#: audits/models.py:39 audits/templates/audits/operate_log_list.html:55 +#: audits/models.py:40 audits/templates/audits/operate_log_list.html:55 #: audits/templates/audits/operate_log_list.html:74 msgid "Resource Type" msgstr "资源类型" -#: audits/models.py:40 audits/templates/audits/operate_log_list.html:75 +#: audits/models.py:41 audits/templates/audits/operate_log_list.html:75 msgid "Resource" msgstr "资源" -#: audits/models.py:51 audits/templates/audits/password_change_log_list.html:57 +#: audits/models.py:52 audits/templates/audits/password_change_log_list.html:57 msgid "Change by" msgstr "修改者" -#: audits/models.py:70 users/templates/users/user_detail.html:98 +#: audits/models.py:71 users/templates/users/user_detail.html:98 msgid "Disabled" msgstr "禁用" -#: audits/models.py:71 settings/models.py:33 +#: audits/models.py:72 settings/models.py:33 #: users/templates/users/user_detail.html:96 msgid "Enabled" msgstr "启用" -#: audits/models.py:72 +#: audits/models.py:73 msgid "-" msgstr "" -#: audits/models.py:77 xpack/plugins/cloud/models.py:264 +#: audits/models.py:78 xpack/plugins/cloud/models.py:264 #: xpack/plugins/cloud/models.py:287 msgid "Failed" msgstr "失败" -#: audits/models.py:81 +#: audits/models.py:82 msgid "Login type" msgstr "登录方式" -#: audits/models.py:82 +#: audits/models.py:83 msgid "Login ip" msgstr "登录IP" -#: audits/models.py:83 +#: audits/models.py:84 msgid "Login city" msgstr "登录城市" -#: audits/models.py:84 +#: audits/models.py:85 msgid "User agent" msgstr "Agent" -#: audits/models.py:85 audits/templates/audits/login_log_list.html:62 +#: audits/models.py:86 audits/templates/audits/login_log_list.html:62 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 -#: users/forms.py:174 users/models/user.py:397 +#: users/forms.py:174 users/models/user.py:404 #: users/templates/users/first_login.html:45 msgid "MFA" msgstr "MFA" -#: audits/models.py:86 audits/templates/audits/login_log_list.html:63 +#: audits/models.py:87 audits/templates/audits/login_log_list.html:63 #: xpack/plugins/change_auth_plan/models.py:416 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:15 #: xpack/plugins/cloud/models.py:278 @@ -2291,16 +2292,17 @@ msgstr "MFA" msgid "Reason" msgstr "原因" -#: audits/models.py:87 audits/templates/audits/login_log_list.html:64 +#: audits/models.py:88 audits/templates/audits/login_log_list.html:64 #: orders/templates/orders/login_confirm_order_detail.html:35 #: orders/templates/orders/login_confirm_order_list.html:17 +#: orders/templates/orders/login_confirm_order_list.html:91 #: xpack/plugins/cloud/models.py:275 xpack/plugins/cloud/models.py:310 #: xpack/plugins/cloud/templates/cloud/sync_instance_task_history.html:70 #: xpack/plugins/cloud/templates/cloud/sync_instance_task_instance.html:65 msgid "Status" msgstr "状态" -#: audits/models.py:88 +#: audits/models.py:89 msgid "Date login" msgstr "登录日期" @@ -2355,7 +2357,6 @@ msgstr "Agent" #: audits/templates/audits/login_log_list.html:61 #: orders/templates/orders/login_confirm_order_detail.html:58 -#: orders/templates/orders/login_confirm_order_list.html:16 msgid "City" msgstr "城市" @@ -2522,15 +2523,15 @@ msgid "Private Token" msgstr "ssh密钥" #: authentication/models.py:43 -msgid "login_confirmation_setting" -msgstr "" +msgid "login_confirm_setting" +msgstr "登录复核设置" -#: authentication/models.py:44 +#: authentication/models.py:44 users/templates/users/user_detail.html:265 msgid "Reviewers" -msgstr "" +msgstr "审批人" #: authentication/models.py:44 -msgid "review_login_confirmation_settings" +msgid "review_login_confirm_settings" msgstr "" #: authentication/models.py:53 @@ -2571,14 +2572,14 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: users/models/user.py:332 users/templates/users/user_profile.html:94 +#: users/models/user.py:339 users/templates/users/user_profile.html:94 #: users/templates/users/user_profile.html:163 #: users/templates/users/user_profile.html:166 msgid "Disable" msgstr "禁用" #: authentication/templates/authentication/_access_key_modal.html:67 -#: users/models/user.py:333 users/templates/users/user_profile.html:92 +#: users/models/user.py:340 users/templates/users/user_profile.html:92 #: users/templates/users/user_profile.html:170 msgid "Enable" msgstr "启用" @@ -2838,8 +2839,9 @@ msgid "" "configure nginx for url distribution, If you see this page, " "prove that you are not accessing the nginx listening port. Good luck." msgstr "" -"
    Koko是单独部署的一个程序,你需要部署Koko, 并确保nginx配置转发,
    如果你看到了" -"这个页面,证明你访问的不是nginx监听的端口,祝你好运
    " +"
    Koko是单独部署的一个程序,你需要部署Koko, 并确保nginx配置转发,
    如果你看到了这个页面,证明你访问的不是nginx监听的端口,祝你好运" #: ops/api/celery.py:54 msgid "Waiting task start" @@ -3113,6 +3115,7 @@ msgid "No system user was selected" msgstr "没有选择系统用户" #: ops/templates/ops/command_execution_create.html:296 orders/models.py:26 +#: orders/templates/orders/login_confirm_order_list.html:92 msgid "Pending" msgstr "等待" @@ -3197,26 +3200,23 @@ msgid "Command execution" msgstr "命令执行" #: orders/models.py:12 orders/models.py:33 -#, fuzzy -#| msgid "User is inactive" msgid "User display name" -msgstr "用户已禁用" +msgstr "用户显示名称" #: orders/models.py:13 orders/models.py:36 msgid "Body" -msgstr "" +msgstr "内容" -#: orders/models.py:24 -#, fuzzy -#| msgid "Accept" +#: orders/models.py:24 orders/templates/orders/login_confirm_order_list.html:93 msgid "Accepted" -msgstr "接受" +msgstr "已接受" -#: orders/models.py:25 +#: orders/models.py:25 orders/templates/orders/login_confirm_order_list.html:94 msgid "Rejected" -msgstr "拒绝" +msgstr "已拒绝" -#: orders/models.py:35 orders/templates/orders/login_confirm_order_list.html:13 +#: orders/models.py:35 orders/templates/orders/login_confirm_order_list.html:14 +#: orders/templates/orders/login_confirm_order_list.html:90 msgid "Title" msgstr "标题" @@ -3240,14 +3240,14 @@ msgstr "待处理人名称" #: orders/serializers.py:21 #: orders/templates/orders/login_confirm_order_detail.html:94 -#: orders/templates/orders/login_confirm_order_list.html:53 +#: orders/templates/orders/login_confirm_order_list.html:59 #: terminal/templates/terminal/terminal_list.html:78 msgid "Accept" msgstr "接受" #: orders/serializers.py:22 #: orders/templates/orders/login_confirm_order_detail.html:95 -#: orders/templates/orders/login_confirm_order_list.html:54 +#: orders/templates/orders/login_confirm_order_list.html:60 #: terminal/templates/terminal/terminal_list.html:80 msgid "Reject" msgstr "拒绝" @@ -3256,16 +3256,16 @@ msgstr "拒绝" msgid "this order" msgstr "这个工单" -#: orders/signals_handler.py:21 -#, fuzzy -#| msgid "New node" -msgid "New order" -msgstr "新节点" +#: orders/templates/orders/login_confirm_order_detail.html:75 +msgid "ago" +msgstr "前" -# msgid "Update user" -# msgstr "更新用户" -#: orders/signals_handler.py:24 -#, fuzzy, python-brace-format +#: orders/utils.py:18 +msgid "New order" +msgstr "新工单" + +#: orders/utils.py:21 +#, python-brace-format msgid "" "\n" "
    \n" @@ -3275,6 +3275,8 @@ msgid "" "
    \n" " User: {user}\n" "
    \n" +" Assignees: {order.assignees_display}\n" +"
    \n" " City: {order.city}\n" "
    \n" " IP: {order.ip}\n" @@ -3292,6 +3294,8 @@ msgstr "" "
    \n" " 用户: {user}\n" "
    \n" +" 待处理人: {order.assignees_display}\n" +"
    \n" " 城市: {order.city}\n" "
    \n" " IP: {order.ip}\n" @@ -3301,19 +3305,40 @@ msgstr "" "
    \n" " " -#: orders/templates/orders/login_confirm_order_detail.html:75 -msgid "ago" -msgstr "前" +#: orders/utils.py:52 +msgid "Order has been reply" +msgstr "工单已被回复" -#: orders/templates/orders/login_confirm_order_list.html:83 -#: users/templates/users/user_list.html:327 -msgid "User is expired" -msgstr "用户已失效" - -#: orders/templates/orders/login_confirm_order_list.html:86 -#: users/templates/users/user_list.html:330 -msgid "User is inactive" -msgstr "用户已禁用" +#: orders/utils.py:53 +#, python-brace-format +msgid "" +"\n" +"
    \n" +"

    Your order has been replay

    \n" +"
    \n" +" Title: {order.title}\n" +"
    \n" +" Assignee: {order.assignee_display}\n" +"
    \n" +" Status: {order.status_display}\n" +"
    \n" +"
    \n" +"
    \n" +" " +msgstr "" +"\n" +"
    \n" +"

    您的工单已被回复

    \n" +"
    \n" +" 标题: {order.title}\n" +"
    \n" +" 处理人: {order.assignee_display}\n" +"
    \n" +" 状态: {order.status_display}\n" +"
    \n" +"
    \n" +"
    \n" +" " #: orders/views.py:15 orders/views.py:31 templates/_nav.html:127 msgid "Orders" @@ -3351,7 +3376,7 @@ msgstr "提示:RDP 协议不支持单独控制上传或下载文件" #: perms/templates/perms/asset_permission_list.html:118 #: perms/templates/perms/remote_app_permission_list.html:16 #: templates/_nav.html:21 users/forms.py:293 users/models/group.py:26 -#: users/models/user.py:381 users/templates/users/_select_user_modal.html:16 +#: users/models/user.py:388 users/templates/users/_select_user_modal.html:16 #: users/templates/users/user_detail.html:218 #: users/templates/users/user_list.html:38 #: xpack/plugins/orgs/templates/orgs/org_list.html:16 @@ -3393,7 +3418,7 @@ msgstr "资产授权" #: perms/models/base.py:53 #: perms/templates/perms/asset_permission_detail.html:90 #: perms/templates/perms/remote_app_permission_detail.html:82 -#: users/models/user.py:413 users/templates/users/user_detail.html:107 +#: users/models/user.py:420 users/templates/users/user_detail.html:107 #: users/templates/users/user_profile.html:120 msgid "Date expired" msgstr "失效日期" @@ -3957,7 +3982,7 @@ msgid "Please submit the LDAP configuration before import" msgstr "请先提交LDAP配置再进行导入" #: settings/templates/settings/_ldap_list_users_modal.html:32 -#: users/models/user.py:377 users/templates/users/user_detail.html:71 +#: users/models/user.py:384 users/templates/users/user_detail.html:71 #: users/templates/users/user_profile.html:59 msgid "Email" msgstr "邮件" @@ -4362,7 +4387,7 @@ msgstr "批量命令" msgid "Task monitor" msgstr "任务监控" -#: templates/_nav.html:130 +#: templates/_nav.html:130 users/templates/users/user_detail.html:257 msgid "Login confirm" msgstr "登录复核" @@ -4748,7 +4773,7 @@ msgstr "你可以使用ssh客户端工具连接终端" msgid "Could not reset self otp, use profile reset instead" msgstr "不能再该页面重置MFA, 请去个人信息页面重置" -#: users/forms.py:32 users/models/user.py:385 +#: users/forms.py:32 users/models/user.py:392 #: users/templates/users/_select_user_modal.html:15 #: users/templates/users/user_detail.html:87 #: users/templates/users/user_list.html:37 @@ -4867,50 +4892,50 @@ msgstr "选择用户" msgid "User auth from {}, go there change password" msgstr "用户认证源来自 {}, 请去相应系统修改密码" -#: users/models/user.py:128 users/models/user.py:510 +#: users/models/user.py:135 users/models/user.py:517 msgid "Administrator" msgstr "管理员" -#: users/models/user.py:130 +#: users/models/user.py:137 msgid "Application" msgstr "应用程序" -#: users/models/user.py:131 xpack/plugins/orgs/forms.py:30 +#: users/models/user.py:138 xpack/plugins/orgs/forms.py:30 #: xpack/plugins/orgs/templates/orgs/org_list.html:14 msgid "Auditor" msgstr "审计员" -#: users/models/user.py:141 +#: users/models/user.py:148 msgid "Org admin" msgstr "组织管理员" -#: users/models/user.py:143 +#: users/models/user.py:150 msgid "Org auditor" msgstr "组织审计员" -#: users/models/user.py:334 users/templates/users/user_profile.html:90 +#: users/models/user.py:341 users/templates/users/user_profile.html:90 msgid "Force enable" msgstr "强制启用" -#: users/models/user.py:388 +#: users/models/user.py:395 msgid "Avatar" msgstr "头像" -#: users/models/user.py:391 users/templates/users/user_detail.html:82 +#: users/models/user.py:398 users/templates/users/user_detail.html:82 msgid "Wechat" msgstr "微信" -#: users/models/user.py:420 users/templates/users/user_detail.html:103 +#: users/models/user.py:427 users/templates/users/user_detail.html:103 #: users/templates/users/user_list.html:39 #: users/templates/users/user_profile.html:102 msgid "Source" msgstr "用户来源" -#: users/models/user.py:424 +#: users/models/user.py:431 msgid "Date password last updated" msgstr "最后更新密码日期" -#: users/models/user.py:513 +#: users/models/user.py:520 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" @@ -5082,7 +5107,7 @@ msgid "Always young, always with tears in my eyes. Stay foolish Stay hungry" msgstr "永远年轻,永远热泪盈眶 stay foolish stay hungry" #: users/templates/users/reset_password.html:46 -#: users/templates/users/user_detail.html:379 users/utils.py:83 +#: users/templates/users/user_detail.html:430 users/utils.py:83 msgid "Reset password" msgstr "重置密码" @@ -5199,7 +5224,7 @@ msgid "Send reset ssh key mail" msgstr "发送重置密钥邮件" #: users/templates/users/user_detail.html:203 -#: users/templates/users/user_detail.html:467 +#: users/templates/users/user_detail.html:518 msgid "Unblock user" msgstr "解除登录限制" @@ -5207,46 +5232,46 @@ msgstr "解除登录限制" msgid "Unblock" msgstr "解除" -#: users/templates/users/user_detail.html:322 +#: users/templates/users/user_detail.html:373 msgid "Goto profile page enable MFA" msgstr "请去个人信息页面启用自己的MFA" -#: users/templates/users/user_detail.html:378 +#: users/templates/users/user_detail.html:429 msgid "An e-mail has been sent to the user`s mailbox." msgstr "已发送邮件到用户邮箱" -#: users/templates/users/user_detail.html:389 +#: users/templates/users/user_detail.html:440 msgid "This will reset the user password and send a reset mail" msgstr "将失效用户当前密码,并发送重设密码邮件到用户邮箱" -#: users/templates/users/user_detail.html:404 +#: users/templates/users/user_detail.html:455 msgid "" "The reset-ssh-public-key E-mail has been sent successfully. Please inform " "the user to update his new ssh public key." msgstr "重设密钥邮件将会发送到用户邮箱" -#: users/templates/users/user_detail.html:405 +#: users/templates/users/user_detail.html:456 msgid "Reset SSH public key" msgstr "重置SSH密钥" -#: users/templates/users/user_detail.html:415 +#: users/templates/users/user_detail.html:466 msgid "This will reset the user public key and send a reset mail" msgstr "将会失效用户当前密钥,并发送重置邮件到用户邮箱" -#: users/templates/users/user_detail.html:433 +#: users/templates/users/user_detail.html:484 msgid "Successfully updated the SSH public key." msgstr "更新ssh密钥成功" -#: users/templates/users/user_detail.html:434 -#: users/templates/users/user_detail.html:438 +#: users/templates/users/user_detail.html:485 +#: users/templates/users/user_detail.html:489 msgid "User SSH public key update" msgstr "ssh密钥" -#: users/templates/users/user_detail.html:483 +#: users/templates/users/user_detail.html:534 msgid "After unlocking the user, the user can log in normally." msgstr "解除用户登录限制后,此用户即可正常登录" -#: users/templates/users/user_detail.html:497 +#: users/templates/users/user_detail.html:548 msgid "Reset user MFA success" msgstr "重置用户MFA成功" @@ -5299,6 +5324,14 @@ msgstr "删除" msgid "User Deleting failed." msgstr "用户删除失败" +#: users/templates/users/user_list.html:327 +msgid "User is expired" +msgstr "用户已失效" + +#: users/templates/users/user_list.html:330 +msgid "User is inactive" +msgstr "用户已禁用" + #: users/templates/users/user_otp_authentication.html:6 #: users/templates/users/user_password_authentication.html:6 msgid "Authenticate" diff --git a/apps/orders/migrations/0001_initial.py b/apps/orders/migrations/0001_initial.py new file mode 100644 index 000000000..9b1099965 --- /dev/null +++ b/apps/orders/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 2.2.5 on 2019-10-31 10:23 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='LoginConfirmOrder', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('user_display', models.CharField(max_length=128, verbose_name='User display name')), + ('title', models.CharField(max_length=256, verbose_name='Title')), + ('body', models.TextField(verbose_name='Body')), + ('assignee_display', models.CharField(blank=True, max_length=128, null=True, verbose_name='Assignee display name')), + ('assignees_display', models.CharField(blank=True, max_length=128, verbose_name='Assignees display name')), + ('type', models.CharField(choices=[('login_confirm', 'Login confirm')], max_length=16, verbose_name='Type')), + ('status', models.CharField(choices=[('accepted', 'Accepted'), ('rejected', 'Rejected'), ('pending', 'Pending')], default='pending', max_length=16)), + ('ip', models.GenericIPAddressField(blank=True, null=True)), + ('city', models.CharField(blank=True, default='', max_length=16)), + ('assignee', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='loginconfirmorder_handled', to=settings.AUTH_USER_MODEL, verbose_name='Assignee')), + ('assignees', models.ManyToManyField(related_name='loginconfirmorder_assigned', to=settings.AUTH_USER_MODEL, verbose_name='Assignees')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='loginconfirmorder_requested', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'ordering': ('-date_created',), + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('order_id', models.UUIDField()), + ('user_display', models.CharField(max_length=128, verbose_name='User display name')), + ('body', models.TextField(verbose_name='Body')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comments', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'ordering': ('date_created',), + }, + ), + ] diff --git a/apps/orders/models.py b/apps/orders/models.py index c574cacb9..3ed12c8c8 100644 --- a/apps/orders/models.py +++ b/apps/orders/models.py @@ -48,6 +48,14 @@ class BaseOrder(CommonModelMixin): def comments(self): return Comment.objects.filter(order_id=self.id) + @property + def body_as_html(self): + return self.body.replace('\n', '
    ') + + @property + def status_display(self): + return self.get_status_display() + class Meta: abstract = True ordering = ('-date_created',) diff --git a/apps/orders/signals_handler.py b/apps/orders/signals_handler.py index 9e2cdd2e7..60db0c5e5 100644 --- a/apps/orders/signals_handler.py +++ b/apps/orders/signals_handler.py @@ -1,59 +1,32 @@ # -*- coding: utf-8 -*- # -from django.utils.translation import ugettext as _ from django.dispatch import receiver -from django.db.models.signals import m2m_changed -from django.conf import settings +from django.db.models.signals import m2m_changed, post_save -from common.tasks import send_mail_async -from common.utils import get_logger, reverse +from common.utils import get_logger from .models import LoginConfirmOrder +from .utils import ( + send_login_confirm_order_mail_to_assignees, + send_login_confirm_action_mail_to_user +) + logger = get_logger(__name__) -def send_mail(order, assignees): - recipient_list = [user.email for user in assignees] - user = order.user - if not recipient_list: - logger.error("Order not has assignees: {}".format(order.id)) - return - subject = '{}: {}'.format(_("New order"), order.title) - detail_url = reverse('orders:login-confirm-order-detail', - kwargs={'pk': order.id}, external=True) - message = _(""" -
    -

    Your has a new order

    -
    - Title: {order.title} -
    - User: {user} -
    - City: {order.city} -
    - IP: {order.ip} -
    - click here to review -
    -
    - """).format(order=order, user=user, url=detail_url) - if settings.DEBUG: - try: - print(message) - except OSError: - pass - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - @receiver(m2m_changed, sender=LoginConfirmOrder.assignees.through) -def on_login_confirm_order_assignee_set(sender, instance=None, action=None, +def on_login_confirm_order_assignees_set(sender, instance=None, action=None, model=None, pk_set=None, **kwargs): - print(">>>>>>>>>>>>>>>>>>>>>>>.") - print(action) if action == 'post_add': - print("<<<<<<<<<<<<<<<<<<<<") logger.debug('New order create, send mail: {}'.format(instance.id)) assignees = model.objects.filter(pk__in=pk_set) - send_mail(instance, assignees) + print(assignees) + send_login_confirm_order_mail_to_assignees(instance, assignees) + +@receiver(post_save, sender=LoginConfirmOrder) +def on_login_confirm_order_status_change(sender, instance=None, created=False, **kwargs): + if created or instance.status == "pending": + return + logger.debug('Order changed, send mail: {}'.format(instance.id)) + send_login_confirm_action_mail_to_user(instance) diff --git a/apps/orders/templates/orders/login_confirm_order_list.html b/apps/orders/templates/orders/login_confirm_order_list.html index e7b8da90c..c54743c85 100644 --- a/apps/orders/templates/orders/login_confirm_order_list.html +++ b/apps/orders/templates/orders/login_confirm_order_list.html @@ -14,7 +14,6 @@ {% trans 'Title' %} {% trans 'User' %} {% trans 'IP' %} - {% trans 'City' %} {% trans 'Status' %} {% trans 'Datetime' %} {% trans 'Action' %} @@ -38,8 +37,12 @@ function initTable() { cellData = htmlEscape(cellData); var detailBtn = '' + cellData + ''; $(td).html(detailBtn.replace("{{ DEFAULT_PK }}", rowData.id)); - }}, - {targets: 5, createdCell: function (td, cellData, rowData) { + }}, + {targets: 3, createdCell: function (td, cellData, rowData) { + var d = cellData + "(" + rowData.city + ")"; + $(td).html(d) + }}, + {targets: 4, createdCell: function (td, cellData, rowData) { if (cellData === "accepted") { $(td).html('') } else if (cellData === "rejected") { @@ -48,13 +51,13 @@ function initTable() { $(td).html('') } }}, - {targets: 6, createdCell: function (td, cellData) { + {targets: 5, createdCell: function (td, cellData) { var d = toSafeLocalDateStr(cellData); $(td).html(d) }}, - {targets: 7, createdCell: function (td, cellData, rowData) { - var acceptBtn = '{% trans "Accept" %} '; - var rejectBtn = '{% trans "Reject" %}'; + {targets: 6, createdCell: function (td, cellData, rowData) { + var acceptBtn = '{% trans "Accept" %} '; + var rejectBtn = '{% trans "Reject" %}'; acceptBtn = acceptBtn.replace('{{ DEFAULT_PK }}', cellData); rejectBtn = rejectBtn.replace('{{ DEFAULT_PK }}', cellData); var acceptBtnRef = $(acceptBtn); @@ -68,10 +71,10 @@ function initTable() { }}], ajax_url: '{% url "api-orders:login-confirm-order-list" %}', columns: [ - {data: "id"}, {data: "title", className: "text-left"}, {data: "user_display"}, - {data: "ip"}, {data: "city"}, - {data: "status", orderable: false}, - {data: "date_created"}, + {data: "id"}, {data: "title", className: "text-left"}, + {data: "user_display"}, {data: "ip"}, + {data: "status", orderable: false, width: "30px"}, + {data: "date_created", width: "120px"}, {data: "id", orderable: false, width: "100px"} ], op_html: $('#actions').html() @@ -82,7 +85,6 @@ function initTable() { $(document).ready(function(){ initTable(); - $('') var menu = [ {title: "IP", value: "ip"}, {title: "{% trans 'Title' %}", value: "title"}, @@ -93,12 +95,21 @@ $(document).ready(function(){ ]} ]; initTableFilterDropdown('#login_confirm_order_list_table_filter input', menu) -}).on('click', '.expired', function () { - var msg = '{% trans "User is expired" %}'; - toastr.error(msg) -}).on('click', '.inactive', function () { - var msg = '{% trans 'User is inactive' %}'; - toastr.error(msg) +}).on('click', '.btn-action', function () { + var actionCreateUrl = "{% url 'api-orders:login-confirm-order-create-action' pk=DEFAULT_PK %}"; + var orderId = $(this).data('uid'); + actionCreateUrl = actionCreateUrl.replace("{{ DEFAULT_PK }}", orderId); + var action = $(this).data('action'); + var comment = ''; + var data = { + url: actionCreateUrl, + method: 'POST', + body: JSON.stringify({action: action, comment: comment}), + success: function () { + window.location.reload(); + } + }; + requestApi(data); }) {% endblock %} diff --git a/apps/orders/utils.py b/apps/orders/utils.py index ec51c5a2b..6fd3d965d 100644 --- a/apps/orders/utils.py +++ b/apps/orders/utils.py @@ -1,2 +1,62 @@ # -*- coding: utf-8 -*- # +from django.conf import settings +from django.utils.translation import ugettext as _ + +from common.utils import get_logger, reverse +from common.tasks import send_mail_async + +logger = get_logger(__name__) + + +def send_login_confirm_order_mail_to_assignees(order, assignees): + recipient_list = [user.email for user in assignees] + user = order.user + if not recipient_list: + logger.error("Order not has assignees: {}".format(order.id)) + return + subject = '{}: {}'.format(_("New order"), order.title) + detail_url = reverse('orders:login-confirm-order-detail', + kwargs={'pk': order.id}, external=True) + message = _(""" +
    +

    Your has a new order

    +
    + Title: {order.title} +
    + User: {user} +
    + Assignees: {order.assignees_display} +
    + City: {order.city} +
    + IP: {order.ip} +
    + click here to review +
    +
    + """).format(order=order, user=user, url=detail_url) + send_mail_async.delay(subject, message, recipient_list, html_message=message) + + +def send_login_confirm_action_mail_to_user(order): + if not order.user: + logger.error("Order not has user: {}".format(order.id)) + return + user = order.user + recipient_list = [user.email] + subject = '{}: {}'.format(_("Order has been reply"), order.title) + message = _(""" +
    +

    Your order has been replay

    +
    + Title: {order.title} +
    + Assignee: {order.assignee_display} +
    + Status: {order.status_display} +
    +
    +
    + """).format(order=order) + send_mail_async.delay(subject, message, recipient_list, html_message=message) diff --git a/apps/static/js/jumpserver.js b/apps/static/js/jumpserver.js index 89ac43364..8fddac5ed 100644 --- a/apps/static/js/jumpserver.js +++ b/apps/static/js/jumpserver.js @@ -416,6 +416,9 @@ function makeLabel(data) { function parseTableFilter(value) { var cleanValues = []; + if (!value) { + return {} + } var valuesArray = value.split(':'); for (var i=0; i {% include '_copyright.html' %}
    -
    - 2014-2019 -
    diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 4c7c54def..36a60abc8 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -117,6 +117,13 @@ class AuthMixin: return True return False + def get_login_confirm_setting(self): + if hasattr(self, 'login_confirm_setting'): + s = self.login_confirm_setting + if s.reviewers.all().count() and s.is_active: + return s + return False + class RoleMixin: ROLE_ADMIN = 'Admin' diff --git a/apps/users/templates/users/user_detail.html b/apps/users/templates/users/user_detail.html index 8e049938c..b57480b18 100644 --- a/apps/users/templates/users/user_detail.html +++ b/apps/users/templates/users/user_detail.html @@ -7,6 +7,7 @@ + {% endblock %} {% block content %}
    @@ -212,46 +213,82 @@
    - {% if user_object.is_current_org_admin %} -
    -
    - {% trans 'User group' %} -
    -
    - - - - - - - - - - - - {% for group in user_object.groups.all %} - - - - - {% endfor %} - -
    - -
    - -
    - {{ group.name }} - - -
    -
    + {% if user_object.is_current_org_admin or user_object.is_superuser %} +
    +
    + {% trans 'User group' %}
    +
    + + + + + + + + + + + + {% for group in user_object.groups.all %} + + + + + {% endfor %} + +
    + +
    + +
    + {{ group.name }} + + +
    +
    +
    {% endif %} +
    +
    + {% trans 'Login confirm' %} +
    +
    + + + + + + + + + + + {% if user_object.get_login_confirm_setting %} + {% for u in user_object.login_confirm_setting.reviewers.all %} + + + + + {% endfor %} + {% endif %} + +
    + +
    + +
    + {{ u }} + + +
    +
    +
    @@ -263,6 +300,7 @@ {% block custom_foot_js %} {% endblock %} From 11f0024c3301c880ccda5886aec783bb686be2af Mon Sep 17 00:00:00 2001 From: ibuler Date: Thu, 31 Oct 2019 18:43:47 +0800 Subject: [PATCH 14/55] =?UTF-8?q?[Update]=20=E6=B7=BB=E5=8A=A0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=A4=8D=E6=A0=B8=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/tasks/const.py | 5 ++++- apps/jumpserver/conf.py | 3 ++- apps/jumpserver/context_processor.py | 1 + apps/templates/_nav.html | 2 +- apps/users/templates/users/user_detail.html | 5 +++-- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/assets/tasks/const.py b/apps/assets/tasks/const.py index 61a9580ed..6810a00bc 100644 --- a/apps/assets/tasks/const.py +++ b/apps/assets/tasks/const.py @@ -7,7 +7,10 @@ from django.utils.translation import ugettext_lazy as _ ENV_PERIOD_TASK = os.environ.get("PERIOD_TASK", "on") == 'on' -PERIOD_TASK_ENABLED = settings.PERIOD_TASK_ENABLED and ENV_PERIOD_TASK +ENV_PERIOD_TASK_ENABLED = os.environ.get("PERIOD_TASK_ENABLED", "on") == "on" +PERIOD_TASK_ENABLED = settings.CONFIG.PERIOD_TASK_ENABLE \ + and ENV_PERIOD_TASK \ + and ENV_PERIOD_TASK_ENABLED UPDATE_ASSETS_HARDWARE_TASKS = [ { diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 27231af4b..936158bd8 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -394,8 +394,9 @@ defaults = { 'WINDOWS_SSH_DEFAULT_SHELL': 'cmd', 'FLOWER_URL': "127.0.0.1:5555", 'DEFAULT_ORG_SHOW_ALL_USERS': True, - 'PERIOD_TASK_ENABLED': True, + 'PERIOD_TASK_ENABLE': True, 'FORCE_SCRIPT_NAME': '', + 'LOGIN_CONFIRM_ENABLE': False } diff --git a/apps/jumpserver/context_processor.py b/apps/jumpserver/context_processor.py index bb51c3a4f..e940f06f6 100644 --- a/apps/jumpserver/context_processor.py +++ b/apps/jumpserver/context_processor.py @@ -20,6 +20,7 @@ def jumpserver_processor(request): 'SECURITY_MFA_VERIFY_TTL': settings.SECURITY_MFA_VERIFY_TTL, 'FORCE_SCRIPT_NAME': settings.FORCE_SCRIPT_NAME, 'SECURITY_VIEW_AUTH_NEED_MFA': settings.CONFIG.SECURITY_VIEW_AUTH_NEED_MFA, + 'LOGIN_CONFIRM_ENABLE': settings.CONFIG.LOGIN_CONFIRM_ENABLE, } return context diff --git a/apps/templates/_nav.html b/apps/templates/_nav.html index 36a2cb9ed..d23f549ca 100644 --- a/apps/templates/_nav.html +++ b/apps/templates/_nav.html @@ -121,7 +121,7 @@ {% endif %} -{% if request.user.can_admin_current_org %} +{% if request.user.can_admin_current_org and LOGIN_CONFIRM_ENABLE %}
  • {% trans 'Orders' %} diff --git a/apps/users/templates/users/user_detail.html b/apps/users/templates/users/user_detail.html index b57480b18..eed7c8409 100644 --- a/apps/users/templates/users/user_detail.html +++ b/apps/users/templates/users/user_detail.html @@ -212,7 +212,6 @@ - {% if user_object.is_current_org_admin or user_object.is_superuser %}
    @@ -252,7 +251,7 @@
    - {% endif %} + {% if LOGIN_CONFIRM_ENABLE %}
    {% trans 'Login confirm' %} @@ -289,6 +288,8 @@
    + {% endif %} + {% endif %} From 9d201bbf98617d1464669f833b49d615da14d190 Mon Sep 17 00:00:00 2001 From: ibuler Date: Fri, 1 Nov 2019 20:34:56 +0800 Subject: [PATCH 15/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9=20token=20a?= =?UTF-8?q?pi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/api/auth.py | 4 +- apps/authentication/api/token.py | 133 ++++++++++++++++++++--------- apps/authentication/const.py | 10 --- apps/authentication/errors.py | 41 +++++++++ apps/authentication/utils.py | 10 +-- apps/authentication/views/login.py | 8 +- 6 files changed, 144 insertions(+), 62 deletions(-) delete mode 100644 apps/authentication/const.py create mode 100644 apps/authentication/errors.py diff --git a/apps/authentication/api/auth.py b/apps/authentication/api/auth.py index 8b1ab69c0..c7c78422a 100644 --- a/apps/authentication/api/auth.py +++ b/apps/authentication/api/auth.py @@ -22,7 +22,7 @@ from users.utils import ( check_otp_code, increase_login_failed_count, is_block_login, clean_failed_count ) -from .. import const +from .. import errors from ..utils import check_user_valid from ..serializers import OtpVerifySerializer from ..signals import post_auth_success, post_auth_failed @@ -174,7 +174,7 @@ class UserOtpAuthApi(RootOrgViewMixin, APIView): status=401 ) if not check_otp_code(user.otp_secret_key, otp_code): - self.send_auth_signal(success=False, username=user.username, reason=const.mfa_failed) + self.send_auth_signal(success=False, username=user.username, reason=errors.mfa_failed) return Response({'msg': _('MFA certification failed')}, status=401) self.send_auth_signal(success=True, user=user) token, expired_at = user.create_bearer_token(request) diff --git a/apps/authentication/api/token.py b/apps/authentication/api/token.py index 8855ac1c9..e2d5b2a58 100644 --- a/apps/authentication/api/token.py +++ b/apps/authentication/api/token.py @@ -1,23 +1,20 @@ # -*- coding: utf-8 -*- # -import uuid - -from django.core.cache import cache from django.utils.translation import ugettext as _ from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.generics import CreateAPIView -from drf_yasg.utils import swagger_auto_schema -from common.utils import get_request_ip, get_logger +from common.utils import get_request_ip, get_logger, get_object_or_none from users.utils import ( check_otp_code, increase_login_failed_count, is_block_login, clean_failed_count ) +from users.models import User from ..utils import check_user_valid from ..signals import post_auth_success, post_auth_failed -from .. import serializers +from .. import serializers, errors logger = get_logger(__name__) @@ -25,29 +22,41 @@ logger = get_logger(__name__) __all__ = ['TokenCreateApi'] -class AuthFailedError(Exception): - def __init__(self, msg, reason=None): - self.msg = msg - self.reason = reason - - -class MFARequiredError(Exception): - pass - - class TokenCreateApi(CreateAPIView): permission_classes = (AllowAny,) serializer_class = serializers.BearerTokenSerializer - @staticmethod - def check_is_block(username, ip): - if is_block_login(username, ip): - msg = _("Log in frequently and try again later") - logger.warn(msg + ': ' + username + ':' + ip) - raise AuthFailedError(msg) + def check_session(self): + pass - def check_user_valid(self): + def get_request_ip(self): + ip = self.request.data.get('remote_addr', None) + ip = ip or get_request_ip(self.request) + return ip + + def check_is_block(self): + username = self.request.data.get("username") + ip = self.get_request_ip() + if is_block_login(username, ip): + msg = errors.ip_blocked + logger.warn(msg + ': ' + username + ':' + ip) + raise errors.AuthFailedError(msg, 'blocked') + + def get_user_from_session(self): + user_id = self.request.session["user_id"] + user = get_object_or_none(User, pk=user_id) + if not user: + error = "Not user in session: {}".format(user_id) + raise errors.AuthFailedError(error, 'session_error') + return user + + def check_user_auth(self): request = self.request + if request.session.get("auth_password") and \ + request.session.get('user_id'): + user = self.get_user_from_session() + return user + self.check_is_block() username = request.data.get('username', '') password = request.data.get('password', '') public_key = request.data.get('public_key', '') @@ -55,34 +64,76 @@ class TokenCreateApi(CreateAPIView): username=username, password=password, public_key=public_key ) + ip = self.get_request_ip() if not user: - raise AuthFailedError(msg) + raise errors.AuthFailedError(msg, error='auth_failed', username=username) + clean_failed_count(username, ip) + request.session['auth_password'] = 1 + request.session['user_id'] = str(user.id) return user + def check_user_mfa_if_need(self, user): + if self.request.session.get('auth_mfa'): + return True + if not user.otp_enabled or not user.otp_secret_key: + return True + otp_code = self.request.data.get("otp_code") + if not otp_code: + raise errors.MFARequiredError() + if not check_otp_code(user.otp_secret_key, otp_code): + raise errors.AuthFailedError( + errors.mfa_failed, error='mfa_failed', + username=user.username, + ) + return True + + def check_user_login_confirm_if_need(self, user): + from orders.models import LoginConfirmOrder + confirm_setting = user.get_login_confirm_setting() + if self.request.session.get('auth_confirm') or not confirm_setting: + return + order = None + if self.request.session.get('auth_order_id'): + order_id = self.request.session['auth_order_id'] + order = get_object_or_none(LoginConfirmOrder, pk=order_id) + if not order: + order = confirm_setting.create_confirm_order(self.request) + self.request.session['auth_order_id'] = str(order.id) + + if order.status == "accepted": + return + elif order.status == "rejected": + raise errors.LoginConfirmRejectedError() + else: + raise errors.LoginConfirmWaitError() + def create(self, request, *args, **kwargs): - username = self.request.data.get('username') - ip = self.request.data.get('remote_addr', None) - ip = ip or get_request_ip(self.request) - user = None + self.check_session() + # 如果认证没有过,检查账号密码 try: - self.check_is_block(username, ip) - user = self.check_user_valid() - if user.otp_enabled: - raise MFARequiredError() + user = self.check_user_auth() + self.check_user_mfa_if_need(user) + self.check_user_login_confirm_if_need(user) self.send_auth_signal(success=True, user=user) - clean_failed_count(username, ip) resp = super().create(request, *args, **kwargs) return resp - except AuthFailedError as e: - increase_login_failed_count(username, ip) - self.send_auth_signal(success=False, user=user, username=username, reason=str(e)) - return Response({'msg': str(e)}, status=401) - except MFARequiredError: + except errors.AuthFailedError as e: + if e.username: + increase_login_failed_count(e.username, self.get_request_ip()) + self.send_auth_signal( + success=False, username=e.username, reason=e.reason + ) + return Response({'msg': e.reason, 'error': e.error}, status=401) + except errors.MFARequiredError: msg = _("MFA required") - seed = uuid.uuid4().hex - cache.set(seed, user.username, 300) - data = {'msg': msg, "choices": ["otp"], "req": seed} + data = {'msg': msg, "choices": ["otp"], "error": 'mfa_required'} return Response(data, status=300) + except errors.LoginConfirmRejectedError as e: + pass + except errors.LoginConfirmWaitError as e: + pass + except errors.LoginConfirmRequiredError as e: + pass def send_auth_signal(self, success=True, user=None, username='', reason=''): if success: diff --git a/apps/authentication/const.py b/apps/authentication/const.py deleted file mode 100644 index 0d1f3de7b..000000000 --- a/apps/authentication/const.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -# -from django.utils.translation import ugettext_lazy as _ - - -password_failed = _('Username/password check failed') -mfa_failed = _('MFA authentication failed') -user_not_exist = _("Username does not exist") -password_expired = _("Password expired") -user_invalid = _('Disabled or expired') diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py new file mode 100644 index 000000000..4b104625d --- /dev/null +++ b/apps/authentication/errors.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# +from django.utils.translation import ugettext_lazy as _ + +password_failed = _('Username/password check failed') +mfa_failed = _('MFA authentication failed') +user_not_exist = _("Username does not exist") +password_expired = _("Password expired") +user_invalid = _('Disabled or expired') +ip_blocked = _("Log in frequently and try again later") + +mfa_required = _("MFA required") +login_confirm_required = _("Login confirm required") +login_confirm_wait = _("Wait login confirm") + + +class AuthFailedError(Exception): + def __init__(self, reason, error=None, username=None): + self.reason = reason + self.error = error + self.username = username + + +class MFARequiredError(Exception): + reason = mfa_required + error = 'mfa_required' + + +class LoginConfirmRequiredError(Exception): + reason = login_confirm_required + error = 'login_confirm_required' + + +class LoginConfirmWaitError(Exception): + reason = login_confirm_wait + error = 'login_confirm_wait' + + +class LoginConfirmRejectedError(Exception): + reason = login_confirm_wait + error = 'login_confirm_rejected' diff --git a/apps/authentication/utils.py b/apps/authentication/utils.py index 85b486bf3..c96878e38 100644 --- a/apps/authentication/utils.py +++ b/apps/authentication/utils.py @@ -8,7 +8,7 @@ from common.utils import ( get_ip_city, get_object_or_none, validate_ip, get_request_ip ) from users.models import User -from . import const +from . import errors def write_login_log(*args, **kwargs): @@ -38,11 +38,11 @@ def check_user_valid(**kwargs): user = None if user is None: - return None, const.user_not_exist + return None, errors.user_not_exist elif not user.is_valid: - return None, const.user_invalid + return None, errors.user_invalid elif user.password_has_expired: - return None, const.password_expired + return None, errors.password_expired if password and authenticate(username=username, password=password): return user, '' @@ -55,4 +55,4 @@ def check_user_valid(**kwargs): elif len(public_key_saved) > 1: if public_key == public_key_saved[1]: return user, '' - return None, const.password_failed + return None, errors.password_failed diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 761e656a1..f8e5984b1 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -27,7 +27,7 @@ from users.utils import ( from ..models import LoginConfirmSetting from ..signals import post_auth_success, post_auth_failed from .. import forms -from .. import const +from .. import errors __all__ = [ @@ -83,7 +83,7 @@ class UserLoginView(FormView): user = form.get_user() # user password expired if user.password_has_expired: - reason = const.password_expired + reason = errors.password_expired self.send_auth_signal(success=False, username=user.username, reason=reason) return self.render_to_response(self.get_context_data(password_expired=True)) @@ -99,7 +99,7 @@ class UserLoginView(FormView): # write login failed log username = form.cleaned_data.get('username') exist = User.objects.filter(username=username).first() - reason = const.password_failed if exist else const.user_not_exist + reason = errors.password_failed if exist else errors.user_not_exist # limit user login failed count ip = get_request_ip(self.request) increase_login_failed_count(username, ip) @@ -150,7 +150,7 @@ class UserLoginOtpView(FormView): else: self.send_auth_signal( success=False, username=user.username, - reason=const.mfa_failed + reason=errors.mfa_failed ) form.add_error( 'otp_code', _('MFA code invalid, or ntp sync server time') From 6ce9815d51613302b06122681e83b66992c6c3f8 Mon Sep 17 00:00:00 2001 From: ibuler Date: Tue, 5 Nov 2019 18:46:29 +0800 Subject: [PATCH 16/55] =?UTF-8?q?[Update]=20=E5=9F=BA=E6=9C=AC=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E7=99=BB=E5=BD=95=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/audits/models.py | 9 + apps/audits/signals_handler.py | 43 +- apps/audits/tasks.py | 6 + .../templates/audits/login_log_list.html | 2 +- apps/audits/utils.py | 18 +- apps/authentication/api/auth.py | 172 +------- apps/authentication/api/login_confirm.py | 33 +- apps/authentication/api/mfa.py | 47 ++- apps/authentication/api/token.py | 128 +----- apps/authentication/errors.py | 189 +++++++-- apps/authentication/forms.py | 38 +- apps/authentication/mixins.py | 127 ++++++ apps/authentication/serializers.py | 67 ++- apps/authentication/signals_handlers.py | 37 +- apps/authentication/tasks.py | 9 - .../templates/authentication/login.html | 27 +- .../authentication/login_wait_confirm.html | 4 +- .../templates/authentication/xpack_login.html | 39 +- apps/authentication/urls/api_urls.py | 3 +- apps/authentication/utils.py | 47 +-- apps/authentication/views/__init__.py | 2 +- apps/authentication/views/login.py | 124 ++---- apps/authentication/views/mfa.py | 25 ++ apps/authentication/views/utils.py | 8 + apps/common/utils/common.py | 8 + apps/locale/zh/LC_MESSAGES/django.mo | Bin 84548 -> 84603 bytes apps/locale/zh/LC_MESSAGES/django.po | 399 +++++++++--------- .../perms/asset_permission_create_update.html | 14 +- .../remote_app_permission_create_update.html | 4 +- apps/static/js/jumpserver.js | 28 ++ apps/templates/_head_css_js.html | 2 +- apps/users/models/user.py | 8 +- apps/users/templates/users/_user.html | 13 +- apps/users/templates/users/user_detail.html | 2 +- apps/users/urls/api_urls.py | 3 - apps/users/utils.py | 8 +- 36 files changed, 874 insertions(+), 819 deletions(-) create mode 100644 apps/authentication/mixins.py create mode 100644 apps/authentication/views/mfa.py create mode 100644 apps/authentication/views/utils.py diff --git a/apps/audits/models.py b/apps/audits/models.py index 5b53d1c85..eb3489a7c 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -110,5 +110,14 @@ class UserLoginLog(models.Model): login_logs = login_logs.filter(username__in=username_list) return login_logs + @property + def reason_display(self): + from authentication.errors import reason_choices, old_reason_choices + reason = reason_choices.get(self.reason) + if reason: + return reason + reason = old_reason_choices.get(self.reason, self.reason) + return reason + class Meta: ordering = ['-datetime', 'username'] diff --git a/apps/audits/signals_handler.py b/apps/audits/signals_handler.py index 0f201b464..d2a728f95 100644 --- a/apps/audits/signals_handler.py +++ b/apps/audits/signals_handler.py @@ -4,15 +4,18 @@ from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from django.db import transaction +from django.utils import timezone from rest_framework.renderers import JSONRenderer +from rest_framework.request import Request from jumpserver.utils import current_request from common.utils import get_request_ip, get_logger, get_syslogger from users.models import User +from authentication.signals import post_auth_failed, post_auth_success from terminal.models import Session, Command from terminal.backends.command.serializers import SessionCommandSerializer -from . import models -from . import serializers +from . import models, serializers +from .tasks import write_login_log_async logger = get_logger(__name__) sys_logger = get_syslogger("audits") @@ -99,3 +102,39 @@ def on_audits_log_create(sender, instance=None, **kwargs): data = json_render.render(s.data).decode(errors='ignore') msg = "{} - {}".format(category, data) sys_logger.info(msg) + + +def generate_data(username, request): + user_agent = request.META.get('HTTP_USER_AGENT', '') + + if isinstance(request, Request): + login_ip = request.data.get('remote_addr', None) + login_type = request.data.get('login_type', '') + else: + login_ip = get_request_ip(request) + login_type = 'W' + + data = { + 'username': username, + 'ip': login_ip, + 'type': login_type, + 'user_agent': user_agent, + 'datetime': timezone.now() + } + return data + + +@receiver(post_auth_success) +def on_user_auth_success(sender, user, request, **kwargs): + logger.debug('User login success: {}'.format(user.username)) + data = generate_data(user.username, request) + data.update({'mfa': int(user.otp_enabled), 'status': True}) + write_login_log_async.delay(**data) + + +@receiver(post_auth_failed) +def on_user_auth_failed(sender, username, request, reason, **kwargs): + logger.debug('User login failed: {}'.format(username)) + data = generate_data(username, request) + data.update({'reason': reason, 'status': False}) + write_login_log_async.delay(**data) diff --git a/apps/audits/tasks.py b/apps/audits/tasks.py index 90d8f47db..5f8da0bc0 100644 --- a/apps/audits/tasks.py +++ b/apps/audits/tasks.py @@ -7,6 +7,7 @@ from celery import shared_task from ops.celery.decorator import register_as_period_task from .models import UserLoginLog +from .utils import write_login_log @register_as_period_task(interval=3600*24) @@ -19,3 +20,8 @@ def clean_login_log_period(): days = 90 expired_day = now - datetime.timedelta(days=days) UserLoginLog.objects.filter(datetime__lt=expired_day).delete() + + +@shared_task +def write_login_log_async(*args, **kwargs): + write_login_log(*args, **kwargs) diff --git a/apps/audits/templates/audits/login_log_list.html b/apps/audits/templates/audits/login_log_list.html index 151fccb13..0533f8aef 100644 --- a/apps/audits/templates/audits/login_log_list.html +++ b/apps/audits/templates/audits/login_log_list.html @@ -78,7 +78,7 @@ {{ login_log.ip }} {{ login_log.city }} {{ login_log.get_mfa_display }} - {% trans login_log.reason %} + {{ login_log.reason_display }} {{ login_log.get_status_display }} {{ login_log.datetime }} diff --git a/apps/audits/utils.py b/apps/audits/utils.py index 36c54e81b..8710bfc0f 100644 --- a/apps/audits/utils.py +++ b/apps/audits/utils.py @@ -1,6 +1,9 @@ import csv import codecs from django.http import HttpResponse +from django.utils.translation import ugettext as _ + +from common.utils import validate_ip, get_ip_city def get_excel_response(filename): @@ -19,4 +22,17 @@ def write_content_to_excel(response, header=None, login_logs=None, fields=None): for log in login_logs: data = [getattr(log, field.name) for field in fields] writer.writerow(data) - return response \ No newline at end of file + return response + + +def write_login_log(*args, **kwargs): + from audits.models import UserLoginLog + default_city = _("Unknown") + ip = kwargs.get('ip') or '' + if not (ip and validate_ip(ip)): + ip = ip[:15] + city = default_city + else: + city = get_ip_city(ip) or default_city + kwargs.update({'ip': ip, 'city': city}) + UserLoginLog.objects.create(**kwargs) diff --git a/apps/authentication/api/auth.py b/apps/authentication/api/auth.py index c7c78422a..cc77058ee 100644 --- a/apps/authentication/api/auth.py +++ b/apps/authentication/api/auth.py @@ -1,114 +1,25 @@ # -*- coding: utf-8 -*- # import uuid -import time from django.core.cache import cache -from django.urls import reverse from django.shortcuts import get_object_or_404 -from django.utils.translation import ugettext as _ from rest_framework.permissions import AllowAny from rest_framework.response import Response -from rest_framework.generics import CreateAPIView from rest_framework.views import APIView -from common.utils import get_logger, get_request_ip, get_object_or_none -from common.permissions import IsOrgAdminOrAppUser, IsValidUser +from common.utils import get_logger +from common.permissions import IsOrgAdminOrAppUser from orgs.mixins.api import RootOrgViewMixin -from users.serializers import UserSerializer from users.models import User from assets.models import Asset, SystemUser -from users.utils import ( - check_otp_code, increase_login_failed_count, - is_block_login, clean_failed_count -) -from .. import errors -from ..utils import check_user_valid -from ..serializers import OtpVerifySerializer -from ..signals import post_auth_success, post_auth_failed logger = get_logger(__name__) __all__ = [ - 'UserAuthApi', 'UserConnectionTokenApi', 'UserOtpAuthApi', - 'UserOtpVerifyApi', 'UserOrderAcceptAuthApi', + 'UserConnectionTokenApi', ] -class UserAuthApi(RootOrgViewMixin, APIView): - permission_classes = (AllowAny,) - serializer_class = UserSerializer - - def get_serializer_context(self): - return { - 'request': self.request, - 'view': self - } - - def get_serializer(self, *args, **kwargs): - kwargs['context'] = self.get_serializer_context() - return self.serializer_class(*args, **kwargs) - - def post(self, request): - # limit login - username = request.data.get('username') - ip = request.data.get('remote_addr', None) - ip = ip or get_request_ip(request) - - if is_block_login(username, ip): - msg = _("Log in frequently and try again later") - logger.warn(msg + ': ' + username + ':' + ip) - return Response({'msg': msg}, status=401) - - user, msg = self.check_user_valid(request) - if not user: - username = request.data.get('username', '') - self.send_auth_signal(success=False, username=username, reason=msg) - increase_login_failed_count(username, ip) - return Response({'msg': msg}, status=401) - - if not user.otp_enabled: - self.send_auth_signal(success=True, user=user) - # 登陆成功,清除原来的缓存计数 - clean_failed_count(username, ip) - token, expired_at = user.create_bearer_token(request) - return Response( - {'token': token, 'user': self.get_serializer(user).data} - ) - - seed = uuid.uuid4().hex - cache.set(seed, user, 300) - return Response( - { - 'code': 101, - 'msg': _('Please carry seed value and ' - 'conduct MFA secondary certification'), - 'otp_url': reverse('api-auth:user-otp-auth'), - 'seed': seed, - 'user': self.get_serializer(user).data - }, status=300 - ) - - @staticmethod - def check_user_valid(request): - username = request.data.get('username', '') - password = request.data.get('password', '') - public_key = request.data.get('public_key', '') - user, msg = check_user_valid( - username=username, password=password, - public_key=public_key - ) - return user, msg - - def send_auth_signal(self, success=True, user=None, username='', reason=''): - if success: - post_auth_success.send(sender=self.__class__, user=user, request=self.request) - else: - post_auth_failed.send( - sender=self.__class__, username=username, - request=self.request, reason=reason - ) - - class UserConnectionTokenApi(RootOrgViewMixin, APIView): permission_classes = (IsOrgAdminOrAppUser,) @@ -150,82 +61,5 @@ class UserConnectionTokenApi(RootOrgViewMixin, APIView): return super().get_permissions() -class UserOtpAuthApi(RootOrgViewMixin, APIView): - permission_classes = (AllowAny,) - serializer_class = UserSerializer - - def get_serializer_context(self): - return { - 'request': self.request, - 'view': self - } - - def get_serializer(self, *args, **kwargs): - kwargs['context'] = self.get_serializer_context() - return self.serializer_class(*args, **kwargs) - - def post(self, request): - otp_code = request.data.get('otp_code', '') - seed = request.data.get('seed', '') - user = cache.get(seed, None) - if not user: - return Response( - {'msg': _('Please verify the user name and password first')}, - status=401 - ) - if not check_otp_code(user.otp_secret_key, otp_code): - self.send_auth_signal(success=False, username=user.username, reason=errors.mfa_failed) - return Response({'msg': _('MFA certification failed')}, status=401) - self.send_auth_signal(success=True, user=user) - token, expired_at = user.create_bearer_token(request) - data = {'token': token, 'user': self.get_serializer(user).data} - return Response(data) - - def send_auth_signal(self, success=True, user=None, username='', reason=''): - if success: - post_auth_success.send(sender=self.__class__, user=user, request=self.request) - else: - post_auth_failed.send( - sender=self.__class__, username=username, - request=self.request, reason=reason - ) -class UserOtpVerifyApi(CreateAPIView): - permission_classes = (IsValidUser,) - serializer_class = OtpVerifySerializer - - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - code = serializer.validated_data["code"] - - if request.user.check_otp(code): - request.session["MFA_VERIFY_TIME"] = int(time.time()) - return Response({"ok": "1"}) - else: - return Response({"error": "Code not valid"}, status=400) - - -class UserOrderAcceptAuthApi(APIView): - permission_classes = () - - def get(self, request, *args, **kwargs): - from orders.models import LoginConfirmOrder - order_id = self.request.session.get("auth_order_id") - logger.debug('Login confirm order id: {}'.format(order_id)) - if not order_id: - order = None - else: - order = get_object_or_none(LoginConfirmOrder, pk=order_id) - if not order: - error = _("No order found or order expired") - return Response({"error": error, "status": "not found"}, status=404) - if order.status == order.STATUS_ACCEPTED: - self.request.session["auth_confirm"] = "1" - return Response({"msg": "ok"}) - elif order.status == order.STATUS_REJECTED: - error = _("Order was rejected by {}").format(order.assignee_display) - else: - error = "Order status: {}".format(order.status) - return Response({"error": error, "status": order.status}, status=400) diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py index 3ce26f84d..45faddac6 100644 --- a/apps/authentication/api/login_confirm.py +++ b/apps/authentication/api/login_confirm.py @@ -1,13 +1,18 @@ # -*- coding: utf-8 -*- # from rest_framework.generics import UpdateAPIView +from rest_framework.response import Response +from rest_framework.views import APIView from django.shortcuts import get_object_or_404 +from common.utils import get_logger, get_object_or_none from common.permissions import IsOrgAdmin from ..models import LoginConfirmSetting from ..serializers import LoginConfirmSettingSerializer +from .. import errors -__all__ = ['LoginConfirmSettingUpdateApi'] +__all__ = ['LoginConfirmSettingUpdateApi', 'UserOrderAcceptAuthApi'] +logger = get_logger(__name__) class LoginConfirmSettingUpdateApi(UpdateAPIView): @@ -23,3 +28,29 @@ class LoginConfirmSettingUpdateApi(UpdateAPIView): defaults, user=user, ) return s + + +class UserOrderAcceptAuthApi(APIView): + permission_classes = () + + def get(self, request, *args, **kwargs): + from orders.models import LoginConfirmOrder + order_id = self.request.session.get("auth_order_id") + logger.debug('Login confirm order id: {}'.format(order_id)) + if not order_id: + order = None + else: + order = get_object_or_none(LoginConfirmOrder, pk=order_id) + try: + if not order: + raise errors.LoginConfirmOrderNotFound(order_id) + if order.status == order.STATUS_ACCEPTED: + self.request.session["auth_confirm"] = "1" + return Response({"msg": "ok"}) + elif order.status == order.STATUS_REJECTED: + raise errors.LoginConfirmRejectedError(order_id) + else: + return errors.LoginConfirmWaitError(order_id) + except errors.AuthFailedError as e: + data = e.as_data() + return Response(data, status=400) diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index 859106e95..3d7cde6ad 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -1,11 +1,56 @@ # -*- coding: utf-8 -*- # +import time from rest_framework.permissions import AllowAny from rest_framework.generics import CreateAPIView +from rest_framework.serializers import ValidationError +from rest_framework.response import Response +from common.permissions import IsValidUser +from ..serializers import OtpVerifySerializer from .. import serializers +from .. import errors +from ..mixins import AuthMixin -class MFAChallengeApi(CreateAPIView): +__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi'] + + +class MFAChallengeApi(AuthMixin, CreateAPIView): permission_classes = (AllowAny,) serializer_class = serializers.MFAChallengeSerializer + + def perform_create(self, serializer): + try: + user = self.get_user_from_session() + code = serializer.validated_data.get('code') + valid = user.check_otp(code) + if not valid: + self.request.session['auth_mfa'] = '' + raise errors.MFAFailedError( + username=user.username, request=self.request + ) + + except errors.AuthFailedError as e: + data = {"error": e.error, "msg": e.reason} + raise ValidationError(data) + + def create(self, request, *args, **kwargs): + super().create(request, *args, **kwargs) + return Response({'msg': 'ok'}) + + +class UserOtpVerifyApi(CreateAPIView): + permission_classes = (IsValidUser,) + serializer_class = OtpVerifySerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + code = serializer.validated_data["code"] + + if request.user.check_otp(code): + request.session["MFA_VERIFY_TIME"] = int(time.time()) + return Response({"ok": "1"}) + else: + return Response({"error": "Code not valid"}, status=400) diff --git a/apps/authentication/api/token.py b/apps/authentication/api/token.py index e2d5b2a58..e0db1bcc3 100644 --- a/apps/authentication/api/token.py +++ b/apps/authentication/api/token.py @@ -1,20 +1,14 @@ # -*- coding: utf-8 -*- # -from django.utils.translation import ugettext as _ from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.generics import CreateAPIView -from common.utils import get_request_ip, get_logger, get_object_or_none -from users.utils import ( - check_otp_code, increase_login_failed_count, - is_block_login, clean_failed_count -) -from users.models import User -from ..utils import check_user_valid -from ..signals import post_auth_success, post_auth_failed +from common.utils import get_logger + from .. import serializers, errors +from ..mixins import AuthMixin logger = get_logger(__name__) @@ -22,126 +16,24 @@ logger = get_logger(__name__) __all__ = ['TokenCreateApi'] -class TokenCreateApi(CreateAPIView): +class TokenCreateApi(AuthMixin, CreateAPIView): permission_classes = (AllowAny,) serializer_class = serializers.BearerTokenSerializer - def check_session(self): - pass - - def get_request_ip(self): - ip = self.request.data.get('remote_addr', None) - ip = ip or get_request_ip(self.request) - return ip - - def check_is_block(self): - username = self.request.data.get("username") - ip = self.get_request_ip() - if is_block_login(username, ip): - msg = errors.ip_blocked - logger.warn(msg + ': ' + username + ':' + ip) - raise errors.AuthFailedError(msg, 'blocked') - - def get_user_from_session(self): - user_id = self.request.session["user_id"] - user = get_object_or_none(User, pk=user_id) - if not user: - error = "Not user in session: {}".format(user_id) - raise errors.AuthFailedError(error, 'session_error') - return user - - def check_user_auth(self): - request = self.request - if request.session.get("auth_password") and \ - request.session.get('user_id'): - user = self.get_user_from_session() - return user - self.check_is_block() - username = request.data.get('username', '') - password = request.data.get('password', '') - public_key = request.data.get('public_key', '') - user, msg = check_user_valid( - username=username, password=password, - public_key=public_key - ) - ip = self.get_request_ip() - if not user: - raise errors.AuthFailedError(msg, error='auth_failed', username=username) - clean_failed_count(username, ip) - request.session['auth_password'] = 1 - request.session['user_id'] = str(user.id) - return user - - def check_user_mfa_if_need(self, user): - if self.request.session.get('auth_mfa'): - return True - if not user.otp_enabled or not user.otp_secret_key: - return True - otp_code = self.request.data.get("otp_code") - if not otp_code: - raise errors.MFARequiredError() - if not check_otp_code(user.otp_secret_key, otp_code): - raise errors.AuthFailedError( - errors.mfa_failed, error='mfa_failed', - username=user.username, - ) - return True - - def check_user_login_confirm_if_need(self, user): - from orders.models import LoginConfirmOrder - confirm_setting = user.get_login_confirm_setting() - if self.request.session.get('auth_confirm') or not confirm_setting: - return - order = None - if self.request.session.get('auth_order_id'): - order_id = self.request.session['auth_order_id'] - order = get_object_or_none(LoginConfirmOrder, pk=order_id) - if not order: - order = confirm_setting.create_confirm_order(self.request) - self.request.session['auth_order_id'] = str(order.id) - - if order.status == "accepted": - return - elif order.status == "rejected": - raise errors.LoginConfirmRejectedError() - else: - raise errors.LoginConfirmWaitError() + def create_session_if_need(self): + if self.request.session.is_empty(): + self.request.session.create() def create(self, request, *args, **kwargs): - self.check_session() + self.create_session_if_need() # 如果认证没有过,检查账号密码 try: user = self.check_user_auth() self.check_user_mfa_if_need(user) self.check_user_login_confirm_if_need(user) self.send_auth_signal(success=True, user=user) + self.clear_auth_mark() resp = super().create(request, *args, **kwargs) return resp except errors.AuthFailedError as e: - if e.username: - increase_login_failed_count(e.username, self.get_request_ip()) - self.send_auth_signal( - success=False, username=e.username, reason=e.reason - ) - return Response({'msg': e.reason, 'error': e.error}, status=401) - except errors.MFARequiredError: - msg = _("MFA required") - data = {'msg': msg, "choices": ["otp"], "error": 'mfa_required'} - return Response(data, status=300) - except errors.LoginConfirmRejectedError as e: - pass - except errors.LoginConfirmWaitError as e: - pass - except errors.LoginConfirmRequiredError as e: - pass - - def send_auth_signal(self, success=True, user=None, username='', reason=''): - if success: - post_auth_success.send( - sender=self.__class__, user=user, request=self.request - ) - else: - post_auth_failed.send( - sender=self.__class__, username=username, - request=self.request, reason=reason - ) + return Response(e.as_data(), status=401) diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 4b104625d..adafd05b1 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -1,41 +1,180 @@ # -*- coding: utf-8 -*- # from django.utils.translation import ugettext_lazy as _ +from django.urls import reverse +from django.conf import settings -password_failed = _('Username/password check failed') -mfa_failed = _('MFA authentication failed') -user_not_exist = _("Username does not exist") -password_expired = _("Password expired") -user_invalid = _('Disabled or expired') -ip_blocked = _("Log in frequently and try again later") +from .signals import post_auth_failed +from users.utils import ( + increase_login_failed_count, get_login_failed_count +) -mfa_required = _("MFA required") -login_confirm_required = _("Login confirm required") -login_confirm_wait = _("Wait login confirm") +reason_password_failed = 'password_failed' +reason_mfa_failed = 'mfa_failed' +reason_user_not_exist = 'user_not_exist' +reason_password_expired = 'password_expired' +reason_user_invalid = 'user_invalid' +reason_user_inactive = 'user_inactive' + +reason_choices = { + reason_password_failed: _('Username/password check failed'), + reason_mfa_failed: _('MFA authentication failed'), + reason_user_not_exist: _("Username does not exist"), + reason_password_expired: _("Password expired"), + reason_user_invalid: _('Disabled or expired'), + reason_user_inactive: _("This account is inactive.") +} +old_reason_choices = { + '0': '-', + '1': reason_choices[reason_password_failed], + '2': reason_choices[reason_mfa_failed], + '3': reason_choices[reason_user_not_exist], + '4': reason_choices[reason_password_expired], +} + +session_empty_msg = _("No session found, check your cookie") +invalid_login_msg = _( + "The username or password you entered is incorrect, " + "please enter it again. " + "You can also try {times_try} times " + "(The account will be temporarily locked for {block_time} minutes)" +) +block_login_msg = _( + "The account has been locked " + "(please contact admin to unlock it or try again after {} minutes)" +) +mfa_failed_msg = _("MFA code invalid, or ntp sync server time") + +mfa_required_msg = _("MFA required") +login_confirm_required_msg = _("Login confirm required") +login_confirm_wait_msg = _("Wait login confirm order for accept") +login_confirm_rejected_msg = _("Login confirm order was rejected") +login_confirm_order_not_found_msg = _("Order not found") + + +class AuthFailedNeedLogMixin: + username = '' + request = None + error = '' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + post_auth_failed.send( + sender=self.__class__, username=self.username, + request=self.request, reason=self.error + ) + + +class AuthFailedNeedBlockMixin: + username = '' + ip = '' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + increase_login_failed_count(self.username, self.ip) class AuthFailedError(Exception): - def __init__(self, reason, error=None, username=None): - self.reason = reason - self.error = error - self.username = username + username = '' + msg = '' + error = '' + request = None + ip = '' + + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def as_data(self): + return { + 'error': self.error, + 'msg': self.msg, + } -class MFARequiredError(Exception): - reason = mfa_required - error = 'mfa_required' +class CredentialError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFailedError): + def __init__(self, error, username, ip, request): + super().__init__(error=error, username=username, ip=ip, request=request) + times_up = settings.SECURITY_LOGIN_LIMIT_COUNT + times_failed = get_login_failed_count(username, ip) + times_try = int(times_up) - int(times_failed) + block_time = settings.SECURITY_LOGIN_LIMIT_TIME + + default_msg = invalid_login_msg.format( + times_try=times_try, block_time=block_time + ) + if error == reason_password_failed: + self.msg = default_msg + else: + self.msg = reason_choices.get(error, default_msg) -class LoginConfirmRequiredError(Exception): - reason = login_confirm_required - error = 'login_confirm_required' +class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError): + reason = reason_mfa_failed + error = 'mfa_failed' + msg = mfa_failed_msg + + def __init__(self, username, request): + super().__init__(username=username, request=request) -class LoginConfirmWaitError(Exception): - reason = login_confirm_wait - error = 'login_confirm_wait' +class BlockLoginError(AuthFailedNeedBlockMixin, AuthFailedError): + error = 'block_login' + msg = block_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) + + def __init__(self, username, ip): + super().__init__(username=username, ip=ip) -class LoginConfirmRejectedError(Exception): - reason = login_confirm_wait - error = 'login_confirm_rejected' +class SessionEmptyError(AuthFailedError): + msg = session_empty_msg + error = 'session_empty_msg' + + +class MFARequiredError(AuthFailedError): + msg = mfa_required_msg + error = 'mfa_required_msg' + + def as_data(self): + return { + 'error': self.error, + 'msg': self.msg, + 'choices': ['otp'], + 'url': reverse('api-auth:mfa-challenge') + } + + +class LoginConfirmRequiredError(AuthFailedError): + msg = login_confirm_required_msg + error = 'login_confirm_required_msg' + + +class LoginConfirmError(AuthFailedError): + msg = login_confirm_wait_msg + error = 'login_confirm_wait_msg' + + def __init__(self, order_id, **kwargs): + self.order_id = order_id + super().__init__(**kwargs) + + def as_data(self): + return { + "error": self.error, + "msg": self.msg, + "order_id": self.order_id + } + + +class LoginConfirmWaitError(LoginConfirmError): + msg = login_confirm_wait_msg + error = 'login_confirm_wait_msg' + + +class LoginConfirmRejectedError(LoginConfirmError): + msg = login_confirm_rejected_msg + error = 'login_confirm_rejected_msg' + + +class LoginConfirmOrderNotFound(LoginConfirmError): + msg = login_confirm_order_not_found_msg + error = 'login_confirm_order_not_found_msg' diff --git a/apps/authentication/forms.py b/apps/authentication/forms.py index 5316e0d79..1b83a3a55 100644 --- a/apps/authentication/forms.py +++ b/apps/authentication/forms.py @@ -9,53 +9,19 @@ from django.conf import settings from users.utils import get_login_failed_count -class UserLoginForm(AuthenticationForm): +class UserLoginForm(forms.Form): username = forms.CharField(label=_('Username'), max_length=100) password = forms.CharField( label=_('Password'), widget=forms.PasswordInput, max_length=128, strip=False ) - error_messages = { - 'invalid_login': _( - "The username or password you entered is incorrect, " - "please enter it again." - ), - 'inactive': _("This account is inactive."), - 'limit_login': _( - "You can also try {times_try} times " - "(The account will be temporarily locked for {block_time} minutes)" - ), - 'block_login': _( - "The account has been locked " - "(please contact admin to unlock it or try again after {} minutes)" - ) - } - def confirm_login_allowed(self, user): if not user.is_staff: raise forms.ValidationError( self.error_messages['inactive'], - code='inactive',) - - def get_limit_login_error_message(self, username, ip): - times_up = settings.SECURITY_LOGIN_LIMIT_COUNT - times_failed = get_login_failed_count(username, ip) - times_try = int(times_up) - int(times_failed) - block_time = settings.SECURITY_LOGIN_LIMIT_TIME - if times_try <= 0: - error_message = self.error_messages['block_login'] - error_message = error_message.format(block_time) - else: - error_message = self.error_messages['limit_login'] - error_message = error_message.format( - times_try=times_try, block_time=block_time, + code='inactive', ) - return error_message - - def add_limit_login_error(self, username, ip): - error = self.get_limit_login_error_message(username, ip) - self.add_error('password', error) class UserLoginCaptchaForm(UserLoginForm): diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py new file mode 100644 index 000000000..02d728b2d --- /dev/null +++ b/apps/authentication/mixins.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# +import time + +from common.utils import get_object_or_none, get_request_ip, get_logger +from users.models import User +from users.utils import ( + is_block_login, clean_failed_count, increase_login_failed_count +) +from . import errors +from .utils import check_user_valid +from .signals import post_auth_success, post_auth_failed + +logger = get_logger(__name__) + + +class AuthMixin: + request = None + + def get_user_from_session(self): + if self.request.session.is_empty(): + raise errors.SessionEmptyError() + if self.request.user and not self.request.user.is_anonymous: + return self.request.user + user_id = self.request.session.get('user_id') + if not user_id: + user = None + else: + user = get_object_or_none(User, pk=user_id) + if not user: + raise errors.SessionEmptyError() + return user + + def get_request_ip(self): + ip = '' + if hasattr(self.request, 'data'): + ip = self.request.data.get('remote_addr', '') + ip = ip or get_request_ip(self.request) + return ip + + def check_is_block(self): + if hasattr(self.request, 'data'): + username = self.request.data.get("username") + else: + username = self.request.POST.get("username") + ip = self.get_request_ip() + if is_block_login(username, ip): + logger.warn('Ip was blocked' + ': ' + username + ':' + ip) + raise errors.BlockLoginError(username=username, ip=ip) + + def check_user_auth(self): + request = self.request + self.check_is_block() + if hasattr(request, 'data'): + username = request.data.get('username', '') + password = request.data.get('password', '') + public_key = request.data.get('public_key', '') + else: + username = request.POST.get('username', '') + password = request.POST.get('password', '') + public_key = request.POST.get('public_key', '') + user, error = check_user_valid( + username=username, password=password, + public_key=public_key + ) + ip = self.get_request_ip() + if not user: + raise errors.CredentialError( + username=username, error=error, ip=ip, request=request + ) + clean_failed_count(username, ip) + request.session['auth_password'] = 1 + request.session['user_id'] = str(user.id) + return user + + def check_user_mfa_if_need(self, user): + if self.request.session.get('auth_mfa'): + return True + if not user.otp_enabled or not user.otp_secret_key: + return True + raise errors.MFARequiredError() + + def check_user_mfa(self, code): + user = self.get_user_from_session() + ok = user.check_otp(code) + if ok: + self.request.session['auth_mfa'] = 1 + self.request.session['auth_mfa_time'] = time.time() + self.request.session['auth_mfa_type'] = 'otp' + return + raise errors.MFAFailedError(username=user.username, request=self.request) + + def check_user_login_confirm_if_need(self, user): + from orders.models import LoginConfirmOrder + confirm_setting = user.get_login_confirm_setting() + if self.request.session.get('auth_confirm') or not confirm_setting: + return + order = None + if self.request.session.get('auth_order_id'): + order_id = self.request.session['auth_order_id'] + order = get_object_or_none(LoginConfirmOrder, pk=order_id) + if not order: + order = confirm_setting.create_confirm_order(self.request) + self.request.session['auth_order_id'] = str(order.id) + + if order.status == "accepted": + return + elif order.status == "rejected": + raise errors.LoginConfirmRejectedError(order.id) + else: + raise errors.LoginConfirmWaitError(order.id) + + def clear_auth_mark(self): + self.request.session['auth_password'] = '' + self.request.session['auth_mfa'] = '' + self.request.session['auth_confirm'] = '' + + def send_auth_signal(self, success=True, user=None, username='', reason=''): + if success: + post_auth_success.send( + sender=self.__class__, user=user, request=self.request + ) + else: + post_auth_failed.send( + sender=self.__class__, username=username, + request=self.request, reason=reason + ) diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index 7463d30ca..0a2f70dda 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -3,6 +3,7 @@ from django.core.cache import cache from rest_framework import serializers +from common.utils import get_object_or_none from users.models import User from .models import AccessKey, LoginConfirmSetting @@ -24,7 +25,12 @@ class OtpVerifySerializer(serializers.Serializer): code = serializers.CharField(max_length=6, min_length=6) -class BearerTokenMixin(serializers.Serializer): +class BearerTokenSerializer(serializers.Serializer): + username = serializers.CharField(allow_null=True, required=False) + password = serializers.CharField(write_only=True, allow_null=True, + required=False) + public_key = serializers.CharField(write_only=True, allow_null=True, + required=False) token = serializers.CharField(read_only=True) keyword = serializers.SerializerMethodField() date_expired = serializers.DateTimeField(read_only=True) @@ -33,58 +39,35 @@ class BearerTokenMixin(serializers.Serializer): def get_keyword(obj): return 'Bearer' - def create_response(self, username): - request = self.context.get("request") - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise serializers.ValidationError("username %s not exist" % username) + def create(self, validated_data): + request = self.context.get('request') + if request.user and not request.user.is_anonymous: + user = request.user + else: + user_id = request.session.get('user_id') + user = get_object_or_none(User, pk=user_id) + if not user: + raise serializers.ValidationError( + "user id {} not exist".format(user_id) + ) token, date_expired = user.create_bearer_token(request) instance = { - "username": username, + "username": user.username, "token": token, "date_expired": date_expired, } return instance - def update(self, instance, validated_data): - pass - -class BearerTokenSerializer(BearerTokenMixin, serializers.Serializer): - username = serializers.CharField() - password = serializers.CharField(write_only=True, allow_null=True, - required=False) - public_key = serializers.CharField(write_only=True, allow_null=True, - required=False) - - def create(self, validated_data): - username = validated_data.get("username") - return self.create_response(username) - - -class MFAChallengeSerializer(BearerTokenMixin, serializers.Serializer): - req = serializers.CharField(write_only=True) - auth_type = serializers.CharField(write_only=True) +class MFAChallengeSerializer(serializers.Serializer): + auth_type = serializers.CharField(write_only=True, required=False, allow_blank=True) code = serializers.CharField(write_only=True) - def validate_req(self, attr): - username = cache.get(attr) - if not username: - raise serializers.ValidationError("Not valid, may be expired") - self.context["username"] = username - - def validate_code(self, code): - username = self.context["username"] - user = User.objects.get(username=username) - ok = user.check_otp(code) - if not ok: - msg = "Otp code not valid, may be expired" - raise serializers.ValidationError(msg) - def create(self, validated_data): - username = self.context["username"] - return self.create_response(username) + pass + + def update(self, instance, validated_data): + pass class LoginConfirmSettingSerializer(serializers.ModelSerializer): diff --git a/apps/authentication/signals_handlers.py b/apps/authentication/signals_handlers.py index c0b48c61d..538bdd869 100644 --- a/apps/authentication/signals_handlers.py +++ b/apps/authentication/signals_handlers.py @@ -1,18 +1,14 @@ -from rest_framework.request import Request from django.http.request import QueryDict from django.conf import settings from django.dispatch import receiver from django.contrib.auth.signals import user_logged_out -from django.utils import timezone from django_auth_ldap.backend import populate_user -from common.utils import get_request_ip from .backends.openid import new_client from .backends.openid.signals import ( post_create_openid_user, post_openid_login_success ) -from .tasks import write_login_log_async -from .signals import post_auth_success, post_auth_failed +from .signals import post_auth_success @receiver(user_logged_out) @@ -52,35 +48,4 @@ def on_ldap_create_user(sender, user, ldap_user, **kwargs): user.save() -def generate_data(username, request): - user_agent = request.META.get('HTTP_USER_AGENT', '') - if isinstance(request, Request): - login_ip = request.data.get('remote_addr', None) - login_type = request.data.get('login_type', '') - else: - login_ip = get_request_ip(request) - login_type = 'W' - - data = { - 'username': username, - 'ip': login_ip, - 'type': login_type, - 'user_agent': user_agent, - 'datetime': timezone.now() - } - return data - - -@receiver(post_auth_success) -def on_user_auth_success(sender, user, request, **kwargs): - data = generate_data(user.username, request) - data.update({'mfa': int(user.otp_enabled), 'status': True}) - write_login_log_async.delay(**data) - - -@receiver(post_auth_failed) -def on_user_auth_failed(sender, username, request, reason, **kwargs): - data = generate_data(username, request) - data.update({'reason': reason, 'status': False}) - write_login_log_async.delay(**data) diff --git a/apps/authentication/tasks.py b/apps/authentication/tasks.py index d64d92992..08472e931 100644 --- a/apps/authentication/tasks.py +++ b/apps/authentication/tasks.py @@ -6,17 +6,8 @@ from ops.celery.decorator import register_as_period_task from django.contrib.sessions.models import Session from django.utils import timezone -from .utils import write_login_log - - -@shared_task -def write_login_log_async(*args, **kwargs): - write_login_log(*args, **kwargs) - @register_as_period_task(interval=3600*24) @shared_task def clean_django_sessions(): Session.objects.filter(expire_date__lt=timezone.now()).delete() - - diff --git a/apps/authentication/templates/authentication/login.html b/apps/authentication/templates/authentication/login.html index b31a716b8..5f1c68c5f 100644 --- a/apps/authentication/templates/authentication/login.html +++ b/apps/authentication/templates/authentication/login.html @@ -37,7 +37,6 @@

    {% trans "Changes the world, starting with a little bit." %}

    -
    @@ -47,25 +46,29 @@
    {% csrf_token %} - - {% if block_login %} -

    {% trans 'Log in frequently and try again later' %}

    - {% elif password_expired %} -

    {% trans 'The user password has expired' %}

    - {% elif form.errors %} - {% if 'captcha' in form.errors %} -

    {% trans 'Captcha invalid' %}

    - {% else %} + {% if form.non_field_errors %} +

    {{ form.non_field_errors.as_text }}

    - {% endif %} -

    {{ form.errors.password.as_text }}

    +
    + {% elif form.errors.captcha %} +

    {% trans 'Captcha invalid' %}

    {% endif %}
    + {% if form.errors.username %} +
    +

    {{ form.errors.username.as_text }}

    +
    + {% endif %}
    + {% if form.errors.password %} +
    +

    {{ form.errors.password.as_text }}

    +
    + {% endif %}
    {{ form.captcha }} diff --git a/apps/authentication/templates/authentication/login_wait_confirm.html b/apps/authentication/templates/authentication/login_wait_confirm.html index 0167236df..0b22cbd1c 100644 --- a/apps/authentication/templates/authentication/login_wait_confirm.html +++ b/apps/authentication/templates/authentication/login_wait_confirm.html @@ -86,7 +86,7 @@ function doRequestAuth() { window.location = successUrl; }, error: function (text, data) { - if (data.status !== "pending") { + if (data.error !== "login_confirm_wait") { if (!errorMsgShow) { infoMsgRef.hide(); errorMsgRef.show(); @@ -97,7 +97,7 @@ function doRequestAuth() { clearInterval(checkInterval); $(".copy-btn").attr('disabled', 'disabled') } - errorMsgRef.html(data.error) + errorMsgRef.html(data.msg) }, flash_message: false }) diff --git a/apps/authentication/templates/authentication/xpack_login.html b/apps/authentication/templates/authentication/xpack_login.html index ff61b981c..2035cc360 100644 --- a/apps/authentication/templates/authentication/xpack_login.html +++ b/apps/authentication/templates/authentication/xpack_login.html @@ -48,6 +48,13 @@ float: right; } + .red-fonts { + color: red; + } + + .field-error { + text-align: left; + } @@ -69,30 +76,32 @@
    -
    +
    {% csrf_token %} + {% if form.non_field_errors %}
    - {% if block_login %} -

    {% trans 'Log in frequently and try again later' %}

    -

    {{ form.errors.password.as_text }}

    - {% elif password_expired %} -

    {% trans 'The user password has expired' %}

    - {% elif form.errors %} - {% if 'captcha' in form.errors %} -

    {% trans 'Captcha invalid' %}

    - {% else %} -

    {{ form.non_field_errors.as_text }}

    - {% endif %} -

    {{ form.errors.password.as_text }}

    - {% endif %} +

    {{ form.non_field_errors.as_text }}

    + {% elif form.errors.captcha %} +

    {% trans 'Captcha invalid' %}

    + {% endif %}
    + {% if form.errors.username %} +
    +

    {{ form.errors.username.as_text }}

    +
    + {% endif %}
    + {% if form.errors.password %} +
    +

    {{ form.errors.password.as_text }}

    +
    + {% endif %}
    {{ form.captcha }} @@ -116,4 +125,4 @@
    - \ No newline at end of file + diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index b47e5eb72..57e238192 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -12,12 +12,11 @@ router.register('access-keys', api.AccessKeyViewSet, 'access-key') urlpatterns = [ # path('token/', api.UserToken.as_view(), name='user-token'), - path('auth/', api.UserAuthApi.as_view(), name='user-auth'), + 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('connection-token/', api.UserConnectionTokenApi.as_view(), name='connection-token'), - path('otp/auth/', api.UserOtpAuthApi.as_view(), name='user-otp-auth'), path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'), path('order/auth/', api.UserOrderAcceptAuthApi.as_view(), name='user-order-auth'), path('login-confirm-settings//', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update') diff --git a/apps/authentication/utils.py b/apps/authentication/utils.py index c96878e38..f06b6ef15 100644 --- a/apps/authentication/utils.py +++ b/apps/authentication/utils.py @@ -1,34 +1,21 @@ # -*- coding: utf-8 -*- # -from django.utils.translation import ugettext as _, ugettext_lazy as __ +from django.utils.translation import ugettext as _ from django.contrib.auth import authenticate -from django.utils import timezone from common.utils import ( - get_ip_city, get_object_or_none, validate_ip, get_request_ip + get_ip_city, get_object_or_none, validate_ip ) from users.models import User from . import errors -def write_login_log(*args, **kwargs): - from audits.models import UserLoginLog - default_city = _("Unknown") - ip = kwargs.get('ip') or '' - if not (ip and validate_ip(ip)): - ip = ip[:15] - city = default_city - else: - city = get_ip_city(ip) or default_city - kwargs.update({'ip': ip, 'city': city}) - UserLoginLog.objects.create(**kwargs) - - def check_user_valid(**kwargs): password = kwargs.pop('password', None) public_key = kwargs.pop('public_key', None) email = kwargs.pop('email', None) username = kwargs.pop('username', None) + request = kwargs.get('request') if username: user = get_object_or_none(User, username=username) @@ -38,21 +25,25 @@ def check_user_valid(**kwargs): user = None if user is None: - return None, errors.user_not_exist - elif not user.is_valid: - return None, errors.user_invalid + return None, errors.reason_user_not_exist + elif user.is_expired: + return None, errors.reason_password_expired + elif not user.is_active: + return None, errors.reason_user_inactive elif user.password_has_expired: - return None, errors.password_expired + return None, errors.reason_password_expired - if password and authenticate(username=username, password=password): - return user, '' + if password: + user = authenticate(request, username=username, password=password) + if user: + return user, '' if public_key and user.public_key: public_key_saved = user.public_key.split() if len(public_key_saved) == 1: - if public_key == public_key_saved[0]: - return user, '' - elif len(public_key_saved) > 1: - if public_key == public_key_saved[1]: - return user, '' - return None, errors.password_failed + public_key_saved = public_key_saved[0] + else: + public_key_saved = public_key_saved[1] + if public_key == public_key_saved: + return user, '' + return None, errors.reason_password_failed diff --git a/apps/authentication/views/__init__.py b/apps/authentication/views/__init__.py index 5e7732adc..5a1a40f7a 100644 --- a/apps/authentication/views/__init__.py +++ b/apps/authentication/views/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # - from .login import * +from .mfa import * diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index f8e5984b1..282ec8501 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -16,22 +16,20 @@ from django.views.decorators.debug import sensitive_post_parameters from django.views.generic.base import TemplateView, RedirectView from django.views.generic.edit import FormView from django.conf import settings +from django.urls import reverse_lazy from common.utils import get_request_ip, get_object_or_none from users.models import User from users.utils import ( - check_otp_code, is_block_login, clean_failed_count, get_user_or_tmp_user, - set_tmp_user_to_cache, increase_login_failed_count, + get_user_or_tmp_user, increase_login_failed_count, redirect_user_first_login_or_index ) -from ..models import LoginConfirmSetting from ..signals import post_auth_success, post_auth_failed -from .. import forms -from .. import errors +from .. import forms, mixins, errors __all__ = [ - 'UserLoginView', 'UserLoginOtpView', 'UserLogoutView', + 'UserLoginView', 'UserLogoutView', 'UserLoginGuardView', 'UserLoginWaitConfirmView', ] @@ -39,10 +37,11 @@ __all__ = [ @method_decorator(sensitive_post_parameters(), name='dispatch') @method_decorator(csrf_protect, name='dispatch') @method_decorator(never_cache, name='dispatch') -class UserLoginView(FormView): +class UserLoginView(mixins.AuthMixin, FormView): form_class = forms.UserLoginForm form_class_captcha = forms.UserLoginCaptchaForm key_prefix_captcha = "_LOGIN_INVALID_{}" + redirect_field_name = 'next' def get_template_names(self): template_name = 'authentication/login.html' @@ -69,54 +68,25 @@ class UserLoginView(FormView): request.session.set_test_cookie() return super().get(request, *args, **kwargs) - def post(self, request, *args, **kwargs): - # limit login authentication - ip = get_request_ip(request) - username = self.request.POST.get('username') - if is_block_login(username, ip): - return self.render_to_response(self.get_context_data(block_login=True)) - return super().post(request, *args, **kwargs) - def form_valid(self, form): if not self.request.session.test_cookie_worked(): return HttpResponse(_("Please enable cookies and try again.")) - user = form.get_user() - # user password expired - if user.password_has_expired: - reason = errors.password_expired - self.send_auth_signal(success=False, username=user.username, reason=reason) - return self.render_to_response(self.get_context_data(password_expired=True)) - - set_tmp_user_to_cache(self.request, user) - username = form.cleaned_data.get('username') - ip = get_request_ip(self.request) - # 登陆成功,清除缓存计数 - clean_failed_count(username, ip) - self.request.session['auth_password'] = '1' + try: + self.check_user_auth() + except errors.AuthFailedError as e: + form.add_error(None, e.msg) + ip = self.get_request_ip() + cache.set(self.key_prefix_captcha.format(ip), 1, 3600) + context = self.get_context_data(form=form) + return self.render_to_response(context) return self.redirect_to_guard_view() - def form_invalid(self, form): - # write login failed log - username = form.cleaned_data.get('username') - exist = User.objects.filter(username=username).first() - reason = errors.password_failed if exist else errors.user_not_exist - # limit user login failed count - ip = get_request_ip(self.request) - increase_login_failed_count(username, ip) - form.add_limit_login_error(username, ip) - # show captcha - cache.set(self.key_prefix_captcha.format(ip), 1, 3600) - self.send_auth_signal(success=False, username=username, reason=reason) - - old_form = form - form = self.form_class_captcha(data=form.data) - form._errors = old_form.errors - return super().form_invalid(form) - - @staticmethod - def redirect_to_guard_view(): - continue_url = reverse('authentication:login-guard') - return redirect(continue_url) + def redirect_to_guard_view(self): + guard_url = reverse('authentication:login-guard') + args = self.request.META.get('QUERY_STRING', '') + if args and self.query_string: + guard_url = "%s?%s" % (guard_url, args) + return redirect(guard_url) def get_form_class(self): ip = get_request_ip(self.request) @@ -134,58 +104,34 @@ class UserLoginView(FormView): return super().get_context_data(**kwargs) -class UserLoginOtpView(FormView): - template_name = 'authentication/login_otp.html' - form_class = forms.UserCheckOtpCodeForm +class UserLoginGuardView(mixins.AuthMixin, RedirectView): redirect_field_name = 'next' + login_url = reverse_lazy('authentication:login') + login_otp_url = reverse_lazy('authentication:login-otp') + login_confirm_url = reverse_lazy('authentication:login-wait-confirm') - def form_valid(self, form): - user = get_user_or_tmp_user(self.request) - otp_code = form.cleaned_data.get('otp_code') - otp_secret_key = user.otp_secret_key - - if check_otp_code(otp_secret_key, otp_code): - self.request.session['auth_otp'] = '1' - return UserLoginView.redirect_to_guard_view() - else: - self.send_auth_signal( - success=False, username=user.username, - reason=errors.mfa_failed - ) - form.add_error( - 'otp_code', _('MFA code invalid, or ntp sync server time') - ) - return super().form_invalid(form) - - def send_auth_signal(self, success=True, user=None, username='', reason=''): - if success: - post_auth_success.send(sender=self.__class__, user=user, request=self.request) - else: - post_auth_failed.send( - sender=self.__class__, username=username, - request=self.request, reason=reason - ) - - -class UserLoginGuardView(RedirectView): - redirect_field_name = 'next' + def format_redirect_url(self, url): + args = self.request.META.get('QUERY_STRING', '') + if args and self.query_string: + url = "%s?%s" % (url, args) + return url def get_redirect_url(self, *args, **kwargs): if not self.request.session.get('auth_password'): - return reverse('authentication:login') - - user = get_user_or_tmp_user(self.request) + return self.format_redirect_url(self.login_url) + user = self.get_user_from_session() # 启用并设置了otp if user.otp_enabled and user.otp_secret_key and \ - not self.request.session.get('auth_otp'): - return reverse('authentication:login-otp') + not self.request.session.get('auth_mfa'): + return self.format_redirect_url(self.login_otp_url) confirm_setting = user.get_login_confirm_setting() if confirm_setting and not self.request.session.get('auth_confirm'): order = confirm_setting.create_confirm_order(self.request) self.request.session['auth_order_id'] = str(order.id) - url = reverse('authentication:login-wait-confirm') + url = self.format_redirect_url(self.login_confirm_url) return url self.login_success(user) + self.clear_auth_mark() # 启用但是没有设置otp if user.otp_enabled and not user.otp_secret_key: # 1,2,mfa_setting & F diff --git a/apps/authentication/views/mfa.py b/apps/authentication/views/mfa.py new file mode 100644 index 000000000..c143601cb --- /dev/null +++ b/apps/authentication/views/mfa.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# + +from __future__ import unicode_literals +from django.views.generic.edit import FormView +from .. import forms, errors, mixins +from .utils import redirect_to_guard_view + +__all__ = ['UserLoginOtpView'] + + +class UserLoginOtpView(mixins.AuthMixin, FormView): + template_name = 'authentication/login_otp.html' + form_class = forms.UserCheckOtpCodeForm + redirect_field_name = 'next' + + def form_valid(self, form): + otp_code = form.cleaned_data.get('otp_code') + try: + self.check_user_mfa(otp_code) + return redirect_to_guard_view() + except errors.MFAFailedError as e: + form.add_error('otp_code', e.reason) + return super().form_invalid(form) + diff --git a/apps/authentication/views/utils.py b/apps/authentication/views/utils.py new file mode 100644 index 000000000..182d7390b --- /dev/null +++ b/apps/authentication/views/utils.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# +from django.shortcuts import reverse, redirect + + +def redirect_to_guard_view(): + continue_url = reverse('authentication:login-guard') + return redirect(continue_url) diff --git a/apps/common/utils/common.py b/apps/common/utils/common.py index 2f4ce784c..29f3b471c 100644 --- a/apps/common/utils/common.py +++ b/apps/common/utils/common.py @@ -153,6 +153,14 @@ def get_request_ip(request): return login_ip +def get_request_ip_or_data(request): + ip = '' + if hasattr(request, 'data'): + ip = request.data.get('remote_addr', '') + ip = ip or get_request_ip(request) + return ip + + def validate_ip(ip): try: ipaddress.ip_address(ip) diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 7d21e0d682223b8118cb8487069700ed5d42e1ea..39d51220e02eca0be696c263006d7160b39b4f2a 100644 GIT binary patch delta 19389 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 delta 19350 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\n" "Language-Team: Jumpserver team\n" @@ -96,7 +96,7 @@ msgstr "运行参数" #: terminal/templates/terminal/session_list.html:28 #: terminal/templates/terminal/session_list.html:72 #: xpack/plugins/change_auth_plan/forms.py:73 -#: xpack/plugins/change_auth_plan/models.py:412 +#: xpack/plugins/change_auth_plan/models.py:419 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_create_update.html:46 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_list.html:54 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:13 @@ -145,14 +145,14 @@ msgstr "资产" #: terminal/models.py:260 terminal/templates/terminal/terminal_detail.html:43 #: terminal/templates/terminal/terminal_list.html:29 users/models/group.py:14 #: users/models/user.py:382 users/templates/users/_select_user_modal.html:13 -#: users/templates/users/user_detail.html:63 +#: users/templates/users/user_detail.html:64 #: users/templates/users/user_group_detail.html:55 #: users/templates/users/user_group_list.html:35 #: users/templates/users/user_list.html:35 #: users/templates/users/user_profile.html:51 #: users/templates/users/user_pubkey_update.html:57 #: xpack/plugins/change_auth_plan/forms.py:56 -#: xpack/plugins/change_auth_plan/models.py:63 +#: xpack/plugins/change_auth_plan/models.py:64 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:61 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_list.html:12 #: xpack/plugins/cloud/models.py:59 xpack/plugins/cloud/models.py:144 @@ -198,8 +198,8 @@ msgstr "参数" #: perms/templates/perms/asset_permission_detail.html:98 #: perms/templates/perms/remote_app_permission_detail.html:90 #: users/models/user.py:423 users/serializers/group.py:32 -#: users/templates/users/user_detail.html:111 -#: xpack/plugins/change_auth_plan/models.py:108 +#: users/templates/users/user_detail.html:112 +#: xpack/plugins/change_auth_plan/models.py:109 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:113 #: xpack/plugins/cloud/models.py:80 xpack/plugins/cloud/models.py:179 #: xpack/plugins/gathered_user/models.py:46 @@ -261,11 +261,11 @@ msgstr "创建日期" #: perms/templates/perms/remote_app_permission_detail.html:94 #: settings/models.py:34 terminal/models.py:33 #: terminal/templates/terminal/terminal_detail.html:63 users/models/group.py:15 -#: users/models/user.py:415 users/templates/users/user_detail.html:129 +#: users/models/user.py:415 users/templates/users/user_detail.html:130 #: users/templates/users/user_group_detail.html:67 #: users/templates/users/user_group_list.html:37 #: users/templates/users/user_profile.html:138 -#: xpack/plugins/change_auth_plan/models.py:104 +#: xpack/plugins/change_auth_plan/models.py:105 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:117 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_list.html:19 #: xpack/plugins/cloud/models.py:77 xpack/plugins/cloud/models.py:173 @@ -313,7 +313,7 @@ msgstr "远程应用" #: terminal/templates/terminal/terminal_update.html:45 #: users/templates/users/_user.html:50 #: users/templates/users/user_bulk_update.html:23 -#: users/templates/users/user_detail.html:178 +#: users/templates/users/user_detail.html:179 #: users/templates/users/user_group_create_update.html:31 #: users/templates/users/user_password_update.html:75 #: users/templates/users/user_profile.html:209 @@ -420,7 +420,7 @@ msgstr "详情" #: perms/templates/perms/remote_app_permission_list.html:64 #: terminal/templates/terminal/terminal_detail.html:16 #: terminal/templates/terminal/terminal_list.html:73 -#: users/templates/users/user_detail.html:25 +#: users/templates/users/user_detail.html:26 #: users/templates/users/user_group_detail.html:28 #: users/templates/users/user_group_list.html:20 #: users/templates/users/user_group_list.html:71 @@ -467,7 +467,7 @@ msgstr "更新" #: settings/templates/settings/terminal_setting.html:93 #: settings/templates/settings/terminal_setting.html:115 #: terminal/templates/terminal/terminal_list.html:75 -#: users/templates/users/user_detail.html:30 +#: users/templates/users/user_detail.html:31 #: users/templates/users/user_group_detail.html:32 #: users/templates/users/user_group_list.html:73 #: users/templates/users/user_list.html:111 @@ -606,7 +606,7 @@ msgstr "端口" #: assets/templates/assets/asset_detail.html:196 #: assets/templates/assets/system_user_assets.html:83 #: perms/models/asset_permission.py:81 -#: xpack/plugins/change_auth_plan/models.py:74 +#: xpack/plugins/change_auth_plan/models.py:75 #: xpack/plugins/gathered_user/models.py:31 #: xpack/plugins/gathered_user/templates/gathered_user/task_list.html:17 msgid "Nodes" @@ -700,21 +700,21 @@ msgstr "SSH网关,支持代理SSH,RDP和VNC" #: assets/templates/assets/admin_user_list.html:45 #: assets/templates/assets/domain_gateway_list.html:71 #: assets/templates/assets/system_user_detail.html:62 -#: assets/templates/assets/system_user_list.html:48 audits/models.py:81 +#: assets/templates/assets/system_user_list.html:48 audits/models.py:82 #: audits/templates/audits/login_log_list.html:57 authentication/forms.py:13 -#: authentication/templates/authentication/login.html:65 -#: authentication/templates/authentication/xpack_login.html:92 +#: authentication/templates/authentication/login.html:60 +#: authentication/templates/authentication/xpack_login.html:87 #: ops/models/adhoc.py:189 perms/templates/perms/asset_permission_list.html:70 #: perms/templates/perms/asset_permission_user.html:55 #: perms/templates/perms/remote_app_permission_user.html:54 #: settings/templates/settings/_ldap_list_users_modal.html:30 users/forms.py:13 #: users/models/user.py:380 users/templates/users/_select_user_modal.html:14 -#: users/templates/users/user_detail.html:67 +#: users/templates/users/user_detail.html:68 #: users/templates/users/user_list.html:36 #: users/templates/users/user_profile.html:47 #: xpack/plugins/change_auth_plan/forms.py:58 -#: xpack/plugins/change_auth_plan/models.py:65 -#: xpack/plugins/change_auth_plan/models.py:408 +#: xpack/plugins/change_auth_plan/models.py:66 +#: xpack/plugins/change_auth_plan/models.py:415 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:65 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_list.html:53 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:12 @@ -732,8 +732,8 @@ msgstr "密码或密钥密码" #: assets/templates/assets/_asset_user_auth_update_modal.html:21 #: assets/templates/assets/_asset_user_auth_view_modal.html:27 #: authentication/forms.py:15 -#: authentication/templates/authentication/login.html:68 -#: authentication/templates/authentication/xpack_login.html:95 +#: authentication/templates/authentication/login.html:63 +#: authentication/templates/authentication/xpack_login.html:90 #: settings/forms.py:114 users/forms.py:15 users/forms.py:27 #: users/templates/users/reset_password.html:53 #: users/templates/users/user_password_authentication.html:18 @@ -741,8 +741,8 @@ msgstr "密码或密钥密码" #: users/templates/users/user_profile_update.html:41 #: users/templates/users/user_pubkey_update.html:41 #: users/templates/users/user_update.html:20 -#: xpack/plugins/change_auth_plan/models.py:95 -#: xpack/plugins/change_auth_plan/models.py:263 +#: xpack/plugins/change_auth_plan/models.py:96 +#: xpack/plugins/change_auth_plan/models.py:264 msgid "Password" msgstr "密码" @@ -938,13 +938,13 @@ msgstr "版本" msgid "AuthBook" msgstr "" -#: assets/models/base.py:31 xpack/plugins/change_auth_plan/models.py:99 -#: xpack/plugins/change_auth_plan/models.py:270 +#: assets/models/base.py:31 xpack/plugins/change_auth_plan/models.py:100 +#: xpack/plugins/change_auth_plan/models.py:271 msgid "SSH private key" msgstr "ssh密钥" -#: assets/models/base.py:32 xpack/plugins/change_auth_plan/models.py:102 -#: xpack/plugins/change_auth_plan/models.py:266 +#: assets/models/base.py:32 xpack/plugins/change_auth_plan/models.py:103 +#: xpack/plugins/change_auth_plan/models.py:267 msgid "SSH public key" msgstr "ssh公钥" @@ -965,7 +965,7 @@ msgid "Contact" msgstr "联系人" #: assets/models/cluster.py:22 users/models/user.py:401 -#: users/templates/users/user_detail.html:76 +#: users/templates/users/user_detail.html:77 msgid "Phone" msgstr "手机" @@ -1121,7 +1121,7 @@ msgstr "默认资产组" #: terminal/templates/terminal/command_list.html:65 #: terminal/templates/terminal/session_list.html:27 #: terminal/templates/terminal/session_list.html:71 users/forms.py:319 -#: users/models/user.py:136 users/models/user.py:152 users/models/user.py:509 +#: users/models/user.py:132 users/models/user.py:148 users/models/user.py:509 #: users/serializers/group.py:21 #: users/templates/users/user_group_detail.html:78 #: users/templates/users/user_group_list.html:36 users/views/user.py:250 @@ -1187,7 +1187,7 @@ msgstr "手动登录" #: assets/views/label.py:27 assets/views/label.py:45 assets/views/label.py:73 #: assets/views/system_user.py:29 assets/views/system_user.py:46 #: assets/views/system_user.py:63 assets/views/system_user.py:79 -#: templates/_nav.html:39 xpack/plugins/change_auth_plan/models.py:70 +#: templates/_nav.html:39 xpack/plugins/change_auth_plan/models.py:71 msgid "Assets" msgstr "资产管理" @@ -1236,17 +1236,17 @@ msgstr "系统用户" msgid "%(value)s is not an even number" msgstr "%(value)s is not an even number" -#: assets/models/utils.py:43 assets/tasks/const.py:84 +#: assets/models/utils.py:43 assets/tasks/const.py:87 msgid "Unreachable" msgstr "不可达" -#: assets/models/utils.py:44 assets/tasks/const.py:85 +#: assets/models/utils.py:44 assets/tasks/const.py:88 #: assets/templates/assets/asset_list.html:99 msgid "Reachable" msgstr "可连接" -#: assets/models/utils.py:45 assets/tasks/const.py:86 -#: authentication/utils.py:16 xpack/plugins/license/models.py:78 +#: assets/models/utils.py:45 assets/tasks/const.py:89 audits/utils.py:29 +#: xpack/plugins/license/models.py:78 msgid "Unknown" msgstr "未知" @@ -1332,7 +1332,7 @@ msgstr "测试资产可连接性: {}" #: assets/tasks/asset_user_connectivity.py:27 #: assets/tasks/push_system_user.py:130 -#: xpack/plugins/change_auth_plan/models.py:521 +#: xpack/plugins/change_auth_plan/models.py:528 msgid "The asset {} system platform {} does not support run Ansible tasks" msgstr "资产 {} 系统平台 {} 不支持运行 Ansible 任务" @@ -1470,8 +1470,8 @@ msgstr "请输入密码" #: assets/templates/assets/_asset_user_auth_update_modal.html:68 #: assets/templates/assets/asset_detail.html:302 -#: users/templates/users/user_detail.html:364 -#: users/templates/users/user_detail.html:391 +#: users/templates/users/user_detail.html:366 +#: users/templates/users/user_detail.html:393 #: xpack/plugins/interface/views.py:35 msgid "Update successfully!" msgstr "更新成功" @@ -1481,7 +1481,7 @@ msgid "Asset user auth" msgstr "资产用户信息" #: assets/templates/assets/_asset_user_auth_view_modal.html:54 -#: authentication/templates/authentication/login_wait_confirm.html:117 +#: authentication/templates/authentication/login_wait_confirm.html:114 msgid "Copy success" msgstr "复制成功" @@ -1669,10 +1669,10 @@ msgstr "选择节点" #: settings/templates/settings/terminal_setting.html:168 #: templates/_modal.html:23 terminal/templates/terminal/session_detail.html:112 #: users/templates/users/user_detail.html:271 -#: users/templates/users/user_detail.html:445 -#: users/templates/users/user_detail.html:471 -#: users/templates/users/user_detail.html:494 -#: users/templates/users/user_detail.html:539 +#: users/templates/users/user_detail.html:447 +#: users/templates/users/user_detail.html:473 +#: users/templates/users/user_detail.html:496 +#: users/templates/users/user_detail.html:541 #: users/templates/users/user_group_create_update.html:32 #: users/templates/users/user_group_list.html:120 #: users/templates/users/user_list.html:256 @@ -1750,7 +1750,7 @@ msgstr "资产用户" #: assets/templates/assets/asset_asset_user_list.html:47 #: assets/templates/assets/asset_detail.html:142 #: terminal/templates/terminal/session_detail.html:85 -#: users/templates/users/user_detail.html:140 +#: users/templates/users/user_detail.html:141 #: users/templates/users/user_profile.html:150 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:128 #: xpack/plugins/cloud/templates/cloud/sync_instance_task_detail.html:132 @@ -1777,7 +1777,7 @@ msgid "Disk" msgstr "硬盘" #: assets/templates/assets/asset_detail.html:126 -#: users/templates/users/user_detail.html:115 +#: users/templates/users/user_detail.html:116 #: users/templates/users/user_profile.html:106 msgid "Date joined" msgstr "创建日期" @@ -1791,7 +1791,7 @@ msgstr "创建日期" #: perms/templates/perms/remote_app_permission_detail.html:112 #: terminal/templates/terminal/terminal_list.html:34 #: users/templates/users/_select_user_modal.html:18 -#: users/templates/users/user_detail.html:146 +#: users/templates/users/user_detail.html:147 #: users/templates/users/user_profile.html:63 msgid "Active" msgstr "激活中" @@ -1872,9 +1872,9 @@ msgstr "显示所有子节点资产" #: assets/templates/assets/asset_list.html:417 #: assets/templates/assets/system_user_list.html:129 -#: users/templates/users/user_detail.html:439 -#: users/templates/users/user_detail.html:465 -#: users/templates/users/user_detail.html:533 +#: users/templates/users/user_detail.html:441 +#: users/templates/users/user_detail.html:467 +#: users/templates/users/user_detail.html:535 #: users/templates/users/user_group_list.html:114 #: users/templates/users/user_list.html:250 #: xpack/plugins/interface/templates/interface/interface.html:97 @@ -1888,9 +1888,9 @@ msgstr "删除选择资产" #: assets/templates/assets/asset_list.html:421 #: assets/templates/assets/system_user_list.html:133 #: settings/templates/settings/terminal_setting.html:166 -#: users/templates/users/user_detail.html:443 -#: users/templates/users/user_detail.html:469 -#: users/templates/users/user_detail.html:537 +#: users/templates/users/user_detail.html:445 +#: users/templates/users/user_detail.html:471 +#: users/templates/users/user_detail.html:539 #: users/templates/users/user_group_list.html:118 #: users/templates/users/user_list.html:254 #: xpack/plugins/interface/templates/interface/interface.html:101 @@ -2214,11 +2214,11 @@ msgstr "操作" msgid "Filename" msgstr "文件名" -#: audits/models.py:24 audits/models.py:77 +#: audits/models.py:24 audits/models.py:78 #: audits/templates/audits/ftp_log_list.html:79 #: ops/templates/ops/command_execution_list.html:68 #: ops/templates/ops/task_list.html:15 -#: users/templates/users/user_detail.html:515 +#: users/templates/users/user_detail.html:517 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:14 #: xpack/plugins/cloud/api.py:61 msgid "Success" @@ -2243,12 +2243,12 @@ msgstr "资源" msgid "Change by" msgstr "修改者" -#: audits/models.py:71 users/templates/users/user_detail.html:98 +#: audits/models.py:71 users/templates/users/user_detail.html:99 msgid "Disabled" msgstr "禁用" #: audits/models.py:72 settings/models.py:33 -#: users/templates/users/user_detail.html:96 +#: users/templates/users/user_detail.html:97 msgid "Enabled" msgstr "启用" @@ -2256,43 +2256,43 @@ msgstr "启用" msgid "-" msgstr "" -#: audits/models.py:78 xpack/plugins/cloud/models.py:264 +#: audits/models.py:79 xpack/plugins/cloud/models.py:264 #: xpack/plugins/cloud/models.py:287 msgid "Failed" msgstr "失败" -#: audits/models.py:82 +#: audits/models.py:83 msgid "Login type" msgstr "登录方式" -#: audits/models.py:83 +#: audits/models.py:84 msgid "Login ip" msgstr "登录IP" -#: audits/models.py:84 +#: audits/models.py:85 msgid "Login city" msgstr "登录城市" -#: audits/models.py:85 +#: audits/models.py:86 msgid "User agent" msgstr "Agent" -#: audits/models.py:86 audits/templates/audits/login_log_list.html:62 +#: audits/models.py:87 audits/templates/audits/login_log_list.html:62 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 #: users/forms.py:174 users/models/user.py:404 #: users/templates/users/first_login.html:45 msgid "MFA" msgstr "MFA" -#: audits/models.py:87 audits/templates/audits/login_log_list.html:63 -#: xpack/plugins/change_auth_plan/models.py:416 +#: audits/models.py:88 audits/templates/audits/login_log_list.html:63 +#: xpack/plugins/change_auth_plan/models.py:423 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:15 #: xpack/plugins/cloud/models.py:278 #: xpack/plugins/cloud/templates/cloud/sync_instance_task_history.html:69 msgid "Reason" msgstr "原因" -#: audits/models.py:88 audits/templates/audits/login_log_list.html:64 +#: audits/models.py:89 audits/templates/audits/login_log_list.html:64 #: orders/templates/orders/login_confirm_order_detail.html:35 #: orders/templates/orders/login_confirm_order_list.html:17 #: orders/templates/orders/login_confirm_order_list.html:91 @@ -2302,7 +2302,7 @@ msgstr "原因" msgid "Status" msgstr "状态" -#: audits/models.py:89 +#: audits/models.py:90 msgid "Date login" msgstr "登录日期" @@ -2314,8 +2314,8 @@ msgstr "登录日期" #: perms/templates/perms/asset_permission_detail.html:86 #: perms/templates/perms/remote_app_permission_detail.html:78 #: terminal/models.py:167 terminal/templates/terminal/session_list.html:34 -#: xpack/plugins/change_auth_plan/models.py:249 -#: xpack/plugins/change_auth_plan/models.py:419 +#: xpack/plugins/change_auth_plan/models.py:250 +#: xpack/plugins/change_auth_plan/models.py:426 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_list.html:59 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:17 #: xpack/plugins/gathered_user/models.py:143 @@ -2391,9 +2391,7 @@ msgstr "登录日志" msgid "Command execution log" msgstr "命令执行" -#: authentication/api/auth.py:58 authentication/api/token.py:45 -#: authentication/templates/authentication/login.html:52 -#: authentication/templates/authentication/xpack_login.html:77 +#: authentication/api/auth.py:58 msgid "Log in frequently and try again later" msgstr "登录频繁, 稍后重试" @@ -2409,18 +2407,6 @@ msgstr "请先进行用户名和密码验证" msgid "MFA certification failed" msgstr "MFA认证失败" -#: authentication/api/auth.py:222 -msgid "No order found or order expired" -msgstr "没有找到工单,或者已过期" - -#: authentication/api/auth.py:228 -msgid "Order was rejected by {}" -msgstr "工单被拒绝 {}" - -#: authentication/api/token.py:81 -msgid "MFA required" -msgstr "" - #: authentication/backends/api.py:53 msgid "Invalid signature header. No credentials provided." msgstr "" @@ -2472,49 +2458,75 @@ msgstr "" msgid "Invalid token or cache refreshed." msgstr "" -#: authentication/const.py:6 +#: authentication/errors.py:20 msgid "Username/password check failed" msgstr "用户名/密码 校验失败" -#: authentication/const.py:7 +#: authentication/errors.py:21 msgid "MFA authentication failed" msgstr "MFA 认证失败" -#: authentication/const.py:8 +#: authentication/errors.py:22 msgid "Username does not exist" msgstr "用户名不存在" -#: authentication/const.py:9 +#: authentication/errors.py:23 msgid "Password expired" -msgstr "密码过期" +msgstr "密码已过期" -#: authentication/const.py:10 +#: authentication/errors.py:24 msgid "Disabled or expired" msgstr "禁用或失效" -#: authentication/forms.py:21 -msgid "" -"The username or password you entered is incorrect, please enter it again." -msgstr "您输入的用户名或密码不正确,请重新输入。" - -#: authentication/forms.py:24 +#: authentication/errors.py:25 msgid "This account is inactive." -msgstr "此账户无效" +msgstr "此账户已禁用" -#: authentication/forms.py:26 +#: authentication/errors.py:28 +msgid "No session found, check your cookie" +msgstr "会话已变更,刷新页面" + +#: authentication/errors.py:30 #, python-brace-format msgid "" +"The username or password you entered is incorrect, please enter it again. " "You can also try {times_try} times (The account will be temporarily locked " "for {block_time} minutes)" -msgstr "您还可以尝试 {times_try} 次(账号将被临时锁定 {block_time} 分钟)" +msgstr "" +"您输入的用户名或密码不正确,请重新输入。 您还可以尝试 {times_try} 次(账号将" +"被临时 锁定 {block_time} 分钟)" -#: authentication/forms.py:30 +#: authentication/errors.py:36 msgid "" "The account has been locked (please contact admin to unlock it or try again " "after {} minutes)" msgstr "账号已被锁定(请联系管理员解锁 或 {}分钟后重试)" -#: authentication/forms.py:66 users/forms.py:21 +#: authentication/errors.py:39 users/views/user.py:393 users/views/user.py:418 +msgid "MFA code invalid, or ntp sync server time" +msgstr "MFA验证码不正确,或者服务器端时间不对" + +#: authentication/errors.py:41 +msgid "MFA required" +msgstr "" + +#: authentication/errors.py:42 +msgid "Login confirm required" +msgstr "需要登录复核" + +#: authentication/errors.py:43 +msgid "Wait login confirm order for accept" +msgstr "等待登录复核处理" + +#: authentication/errors.py:44 +msgid "Login confirm order was rejected" +msgstr "登录已被拒绝" + +#: authentication/errors.py:45 +msgid "Order not found" +msgstr "没有发现工单" + +#: authentication/forms.py:32 users/forms.py:21 msgid "MFA code" msgstr "MFA 验证码" @@ -2522,18 +2534,10 @@ msgstr "MFA 验证码" msgid "Private Token" msgstr "ssh密钥" -#: authentication/models.py:43 -msgid "login_confirm_setting" -msgstr "登录复核设置" - #: authentication/models.py:44 users/templates/users/user_detail.html:265 msgid "Reviewers" msgstr "审批人" -#: authentication/models.py:44 -msgid "review_login_confirm_settings" -msgstr "" - #: authentication/models.py:53 msgid "User login confirm: {}" msgstr "用户登录复核: {}" @@ -2572,14 +2576,14 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: users/models/user.py:339 users/templates/users/user_profile.html:94 +#: users/models/user.py:335 users/templates/users/user_profile.html:94 #: users/templates/users/user_profile.html:163 #: users/templates/users/user_profile.html:166 msgid "Disable" msgstr "禁用" #: authentication/templates/authentication/_access_key_modal.html:67 -#: users/models/user.py:340 users/templates/users/user_profile.html:92 +#: users/models/user.py:336 users/templates/users/user_profile.html:92 #: users/templates/users/user_profile.html:170 msgid "Enable" msgstr "启用" @@ -2640,39 +2644,34 @@ msgid "Changes the world, starting with a little bit." msgstr "改变世界,从一点点开始。" #: authentication/templates/authentication/login.html:46 -#: authentication/templates/authentication/login.html:73 -#: authentication/templates/authentication/xpack_login.html:101 +#: authentication/templates/authentication/login.html:68 +#: authentication/templates/authentication/xpack_login.html:96 #: templates/_header_bar.html:83 msgid "Login" msgstr "登录" -#: authentication/templates/authentication/login.html:54 -#: authentication/templates/authentication/xpack_login.html:80 -msgid "The user password has expired" -msgstr "用户密码已过期" - -#: authentication/templates/authentication/login.html:57 -#: authentication/templates/authentication/xpack_login.html:83 +#: authentication/templates/authentication/login.html:52 +#: authentication/templates/authentication/xpack_login.html:78 msgid "Captcha invalid" msgstr "验证码错误" -#: authentication/templates/authentication/login.html:84 -#: authentication/templates/authentication/xpack_login.html:105 +#: authentication/templates/authentication/login.html:79 +#: authentication/templates/authentication/xpack_login.html:100 #: users/templates/users/forgot_password.html:10 #: users/templates/users/forgot_password.html:25 msgid "Forgot password" msgstr "忘记密码" -#: authentication/templates/authentication/login.html:91 +#: authentication/templates/authentication/login.html:86 msgid "More login options" msgstr "更多登录方式" -#: authentication/templates/authentication/login.html:95 +#: authentication/templates/authentication/login.html:90 msgid "Keycloak" msgstr "" #: authentication/templates/authentication/login_otp.html:46 -#: users/templates/users/user_detail.html:91 +#: users/templates/users/user_detail.html:92 #: users/templates/users/user_profile.html:87 msgid "MFA certification" msgstr "MFA认证" @@ -2721,16 +2720,11 @@ msgstr "返回" msgid "Welcome back, please enter username and password to login" msgstr "欢迎回来,请输入用户名和密码登录" -#: authentication/views/login.py:82 +#: authentication/views/login.py:80 msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: authentication/views/login.py:156 users/views/user.py:393 -#: users/views/user.py:418 -msgid "MFA code invalid, or ntp sync server time" -msgstr "MFA验证码不正确,或者服务器端时间不对" - -#: authentication/views/login.py:226 +#: authentication/views/login.py:192 msgid "" "Wait for {} confirm, You also can copy link to her/him
    \n" " Don't close this page" @@ -2738,15 +2732,15 @@ msgstr "" "等待 {} 确认, 你也可以复制链接发给他/她
    \n" " 不要关闭本页面" -#: authentication/views/login.py:231 +#: authentication/views/login.py:197 msgid "No order found" msgstr "没有发现工单" -#: authentication/views/login.py:254 +#: authentication/views/login.py:220 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:255 +#: authentication/views/login.py:221 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" @@ -2930,8 +2924,8 @@ msgstr "完成时间" #: ops/models/adhoc.py:357 ops/templates/ops/adhoc_history.html:57 #: ops/templates/ops/task_history.html:63 ops/templates/ops/task_list.html:17 -#: xpack/plugins/change_auth_plan/models.py:252 -#: xpack/plugins/change_auth_plan/models.py:422 +#: xpack/plugins/change_auth_plan/models.py:253 +#: xpack/plugins/change_auth_plan/models.py:429 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_list.html:58 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:16 #: xpack/plugins/gathered_user/models.py:146 @@ -3305,11 +3299,11 @@ msgstr "" "
    \n" " " -#: orders/utils.py:52 +#: orders/utils.py:48 msgid "Order has been reply" msgstr "工单已被回复" -#: orders/utils.py:53 +#: orders/utils.py:49 #, python-brace-format msgid "" "\n" @@ -3418,7 +3412,7 @@ msgstr "资产授权" #: perms/models/base.py:53 #: perms/templates/perms/asset_permission_detail.html:90 #: perms/templates/perms/remote_app_permission_detail.html:82 -#: users/models/user.py:420 users/templates/users/user_detail.html:107 +#: users/models/user.py:420 users/templates/users/user_detail.html:108 #: users/templates/users/user_profile.html:120 msgid "Date expired" msgstr "失效日期" @@ -3982,7 +3976,7 @@ msgid "Please submit the LDAP configuration before import" msgstr "请先提交LDAP配置再进行导入" #: settings/templates/settings/_ldap_list_users_modal.html:32 -#: users/models/user.py:384 users/templates/users/user_detail.html:71 +#: users/models/user.py:384 users/templates/users/user_detail.html:72 #: users/templates/users/user_profile.html:59 msgid "Email" msgstr "邮件" @@ -4775,7 +4769,7 @@ msgstr "不能再该页面重置MFA, 请去个人信息页面重置" #: users/forms.py:32 users/models/user.py:392 #: users/templates/users/_select_user_modal.html:15 -#: users/templates/users/user_detail.html:87 +#: users/templates/users/user_detail.html:88 #: users/templates/users/user_list.html:37 #: users/templates/users/user_profile.html:55 msgid "Role" @@ -4818,7 +4812,7 @@ msgstr "生成重置密码链接,通过邮件发送给用户" msgid "Set password" msgstr "设置密码" -#: users/forms.py:132 xpack/plugins/change_auth_plan/models.py:88 +#: users/forms.py:132 xpack/plugins/change_auth_plan/models.py:89 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_create_update.html:51 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:69 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_list.html:57 @@ -4892,28 +4886,28 @@ msgstr "选择用户" msgid "User auth from {}, go there change password" msgstr "用户认证源来自 {}, 请去相应系统修改密码" -#: users/models/user.py:135 users/models/user.py:517 +#: users/models/user.py:131 users/models/user.py:517 msgid "Administrator" msgstr "管理员" -#: users/models/user.py:137 +#: users/models/user.py:133 msgid "Application" msgstr "应用程序" -#: users/models/user.py:138 xpack/plugins/orgs/forms.py:30 +#: users/models/user.py:134 xpack/plugins/orgs/forms.py:30 #: xpack/plugins/orgs/templates/orgs/org_list.html:14 msgid "Auditor" msgstr "审计员" -#: users/models/user.py:148 +#: users/models/user.py:144 msgid "Org admin" msgstr "组织管理员" -#: users/models/user.py:150 +#: users/models/user.py:146 msgid "Org auditor" msgstr "组织审计员" -#: users/models/user.py:341 users/templates/users/user_profile.html:90 +#: users/models/user.py:337 users/templates/users/user_profile.html:90 msgid "Force enable" msgstr "强制启用" @@ -4921,11 +4915,11 @@ msgstr "强制启用" msgid "Avatar" msgstr "头像" -#: users/models/user.py:398 users/templates/users/user_detail.html:82 +#: users/models/user.py:398 users/templates/users/user_detail.html:83 msgid "Wechat" msgstr "微信" -#: users/models/user.py:427 users/templates/users/user_detail.html:103 +#: users/models/user.py:427 users/templates/users/user_detail.html:104 #: users/templates/users/user_list.html:39 #: users/templates/users/user_profile.html:102 msgid "Source" @@ -5107,7 +5101,7 @@ msgid "Always young, always with tears in my eyes. Stay foolish Stay hungry" msgstr "永远年轻,永远热泪盈眶 stay foolish stay hungry" #: users/templates/users/reset_password.html:46 -#: users/templates/users/user_detail.html:430 users/utils.py:83 +#: users/templates/users/user_detail.html:432 users/utils.py:83 msgid "Reset password" msgstr "重置密码" @@ -5176,102 +5170,102 @@ msgstr "很强" msgid "Create user" msgstr "创建用户" -#: users/templates/users/user_detail.html:19 +#: users/templates/users/user_detail.html:20 #: users/templates/users/user_granted_asset.html:18 users/views/user.py:190 msgid "User detail" msgstr "用户详情" -#: users/templates/users/user_detail.html:22 +#: users/templates/users/user_detail.html:23 #: users/templates/users/user_granted_asset.html:21 #: users/templates/users/user_group_detail.html:25 #: users/templates/users/user_group_granted_asset.html:21 msgid "Asset granted" msgstr "授权的资产" -#: users/templates/users/user_detail.html:94 +#: users/templates/users/user_detail.html:95 msgid "Force enabled" msgstr "强制启用" -#: users/templates/users/user_detail.html:119 +#: users/templates/users/user_detail.html:120 #: users/templates/users/user_profile.html:110 msgid "Last login" msgstr "最后登录" -#: users/templates/users/user_detail.html:124 +#: users/templates/users/user_detail.html:125 #: users/templates/users/user_profile.html:115 msgid "Last password updated" msgstr "最后更新密码" -#: users/templates/users/user_detail.html:160 +#: users/templates/users/user_detail.html:161 msgid "Force enabled MFA" msgstr "强制启用MFA" -#: users/templates/users/user_detail.html:175 +#: users/templates/users/user_detail.html:176 msgid "Reset MFA" msgstr "重置MFA" -#: users/templates/users/user_detail.html:184 +#: users/templates/users/user_detail.html:185 msgid "Send reset password mail" msgstr "发送重置密码邮件" -#: users/templates/users/user_detail.html:187 -#: users/templates/users/user_detail.html:197 +#: users/templates/users/user_detail.html:188 +#: users/templates/users/user_detail.html:198 msgid "Send" msgstr "发送" -#: users/templates/users/user_detail.html:194 +#: users/templates/users/user_detail.html:195 msgid "Send reset ssh key mail" msgstr "发送重置密钥邮件" -#: users/templates/users/user_detail.html:203 -#: users/templates/users/user_detail.html:518 +#: users/templates/users/user_detail.html:204 +#: users/templates/users/user_detail.html:520 msgid "Unblock user" msgstr "解除登录限制" -#: users/templates/users/user_detail.html:206 +#: users/templates/users/user_detail.html:207 msgid "Unblock" msgstr "解除" -#: users/templates/users/user_detail.html:373 +#: users/templates/users/user_detail.html:375 msgid "Goto profile page enable MFA" msgstr "请去个人信息页面启用自己的MFA" -#: users/templates/users/user_detail.html:429 +#: users/templates/users/user_detail.html:431 msgid "An e-mail has been sent to the user`s mailbox." msgstr "已发送邮件到用户邮箱" -#: users/templates/users/user_detail.html:440 +#: users/templates/users/user_detail.html:442 msgid "This will reset the user password and send a reset mail" msgstr "将失效用户当前密码,并发送重设密码邮件到用户邮箱" -#: users/templates/users/user_detail.html:455 +#: users/templates/users/user_detail.html:457 msgid "" "The reset-ssh-public-key E-mail has been sent successfully. Please inform " "the user to update his new ssh public key." msgstr "重设密钥邮件将会发送到用户邮箱" -#: users/templates/users/user_detail.html:456 +#: users/templates/users/user_detail.html:458 msgid "Reset SSH public key" msgstr "重置SSH密钥" -#: users/templates/users/user_detail.html:466 +#: users/templates/users/user_detail.html:468 msgid "This will reset the user public key and send a reset mail" msgstr "将会失效用户当前密钥,并发送重置邮件到用户邮箱" -#: users/templates/users/user_detail.html:484 +#: users/templates/users/user_detail.html:486 msgid "Successfully updated the SSH public key." msgstr "更新ssh密钥成功" -#: users/templates/users/user_detail.html:485 -#: users/templates/users/user_detail.html:489 +#: users/templates/users/user_detail.html:487 +#: users/templates/users/user_detail.html:491 msgid "User SSH public key update" msgstr "ssh密钥" -#: users/templates/users/user_detail.html:534 +#: users/templates/users/user_detail.html:536 msgid "After unlocking the user, the user can log in normally." msgstr "解除用户登录限制后,此用户即可正常登录" -#: users/templates/users/user_detail.html:548 +#: users/templates/users/user_detail.html:550 msgid "Reset user MFA success" msgstr "重置用户MFA成功" @@ -5754,8 +5748,8 @@ msgstr "" "具

    注意: 如果同时设置了定期执行和周期执行,优先使用定期执行" #: xpack/plugins/change_auth_plan/meta.py:9 -#: xpack/plugins/change_auth_plan/models.py:116 -#: xpack/plugins/change_auth_plan/models.py:256 +#: xpack/plugins/change_auth_plan/models.py:117 +#: xpack/plugins/change_auth_plan/models.py:257 #: xpack/plugins/change_auth_plan/views.py:33 #: xpack/plugins/change_auth_plan/views.py:50 #: xpack/plugins/change_auth_plan/views.py:74 @@ -5766,20 +5760,20 @@ msgstr "" msgid "Change auth plan" msgstr "改密计划" -#: xpack/plugins/change_auth_plan/models.py:57 +#: xpack/plugins/change_auth_plan/models.py:58 msgid "Custom password" msgstr "自定义密码" -#: xpack/plugins/change_auth_plan/models.py:58 +#: xpack/plugins/change_auth_plan/models.py:59 msgid "All assets use the same random password" msgstr "所有资产使用相同的随机密码" -#: xpack/plugins/change_auth_plan/models.py:59 +#: xpack/plugins/change_auth_plan/models.py:60 msgid "All assets use different random password" msgstr "所有资产使用不同的随机密码" -#: xpack/plugins/change_auth_plan/models.py:78 -#: xpack/plugins/change_auth_plan/models.py:147 +#: xpack/plugins/change_auth_plan/models.py:79 +#: xpack/plugins/change_auth_plan/models.py:148 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:100 #: xpack/plugins/cloud/models.py:165 xpack/plugins/cloud/models.py:219 #: xpack/plugins/cloud/templates/cloud/sync_instance_task_detail.html:91 @@ -5788,8 +5782,8 @@ msgstr "所有资产使用不同的随机密码" msgid "Cycle perform" msgstr "周期执行" -#: xpack/plugins/change_auth_plan/models.py:83 -#: xpack/plugins/change_auth_plan/models.py:145 +#: xpack/plugins/change_auth_plan/models.py:84 +#: xpack/plugins/change_auth_plan/models.py:146 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:92 #: xpack/plugins/cloud/models.py:170 xpack/plugins/cloud/models.py:217 #: xpack/plugins/cloud/templates/cloud/sync_instance_task_detail.html:83 @@ -5798,37 +5792,37 @@ msgstr "周期执行" msgid "Regularly perform" msgstr "定期执行" -#: xpack/plugins/change_auth_plan/models.py:92 +#: xpack/plugins/change_auth_plan/models.py:93 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:74 msgid "Password rules" msgstr "密码规则" -#: xpack/plugins/change_auth_plan/models.py:212 +#: xpack/plugins/change_auth_plan/models.py:213 msgid "* For security, do not change {} user's password" msgstr "* 为了安全,禁止更改 {} 用户的密码" -#: xpack/plugins/change_auth_plan/models.py:216 +#: xpack/plugins/change_auth_plan/models.py:217 msgid "Assets is empty, please add the asset" msgstr "资产为空,请添加资产" -#: xpack/plugins/change_auth_plan/models.py:260 +#: xpack/plugins/change_auth_plan/models.py:261 msgid "Change auth plan snapshot" msgstr "改密计划快照" -#: xpack/plugins/change_auth_plan/models.py:275 -#: xpack/plugins/change_auth_plan/models.py:426 +#: xpack/plugins/change_auth_plan/models.py:276 +#: xpack/plugins/change_auth_plan/models.py:433 msgid "Change auth plan execution" msgstr "改密计划执行" -#: xpack/plugins/change_auth_plan/models.py:435 +#: xpack/plugins/change_auth_plan/models.py:442 msgid "Change auth plan execution subtask" msgstr "改密计划执行子任务" -#: xpack/plugins/change_auth_plan/models.py:453 +#: xpack/plugins/change_auth_plan/models.py:460 msgid "Authentication failed" msgstr "认证失败" -#: xpack/plugins/change_auth_plan/models.py:455 +#: xpack/plugins/change_auth_plan/models.py:462 msgid "Connection timeout" msgstr "连接超时" @@ -6438,6 +6432,27 @@ msgstr "密码匣子" msgid "vault create" msgstr "创建" +#~ msgid "" +#~ "The username or password you entered is incorrect, please enter it again." +#~ msgstr "您输入的用户名或密码不正确,请重新输入。" + +#~ msgid "" +#~ "You can also try {times_try} times (The account will be temporarily " +#~ "locked for {block_time} minutes)" +#~ msgstr "您还可以尝试 {times_try} 次(账号将被临时锁定 {block_time} 分钟)" + +#~ msgid "No order found or order expired" +#~ msgstr "没有找到工单,或者已过期" + +#~ msgid "Order was rejected by {}" +#~ msgstr "工单被拒绝 {}" + +#~ msgid "login_confirm_setting" +#~ msgstr "登录复核设置" + +#~ msgid "The user password has expired" +#~ msgstr "用户密码已过期" + #~ msgid "Recipient" #~ msgstr "收件人" diff --git a/apps/perms/templates/perms/asset_permission_create_update.html b/apps/perms/templates/perms/asset_permission_create_update.html index 5e4c650d0..0c05de831 100644 --- a/apps/perms/templates/perms/asset_permission_create_update.html +++ b/apps/perms/templates/perms/asset_permission_create_update.html @@ -100,16 +100,6 @@ - \ No newline at end of file + diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 36a60abc8..c987fbd91 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -62,10 +62,6 @@ class AuthMixin: def can_use_ssh_key_login(self): return settings.TERMINAL_PUBLIC_KEY_AUTH - def check_otp(self, code): - from ..utils import check_otp_code - return check_otp_code(self.otp_secret_key, code) - def is_public_key_valid(self): """ Check if the user's ssh public key is valid. @@ -362,6 +358,10 @@ class MFAMixin: self.otp_level = 0 self.otp_secret_key = None + def check_otp(self, code): + from ..utils import check_otp_code + return check_otp_code(self.otp_secret_key, code) + class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): SOURCE_LOCAL = 'local' diff --git a/apps/users/templates/users/_user.html b/apps/users/templates/users/_user.html index 192dbfb70..018a165fa 100644 --- a/apps/users/templates/users/_user.html +++ b/apps/users/templates/users/_user.html @@ -56,6 +56,7 @@ {% endblock %} {% block custom_foot_js %} + @@ -72,19 +73,9 @@ $(groups_id).closest('.form-group').removeClass('hidden'); }} - var dateOptions = { - singleDatePicker: true, - showDropdowns: true, - timePicker: true, - timePicker24Hour: true, - autoApply: true, - locale: { - format: 'YYYY-MM-DD HH:mm' - } - }; $(document).ready(function () { $('.select2').select2(); - $('#id_date_expired').daterangepicker(dateOptions); + initDateRangePicker('#id_date_expired'); var mfa_radio = $('#id_otp_level'); mfa_radio.addClass("form-inline"); mfa_radio.children().css("margin-right","15px"); diff --git a/apps/users/templates/users/user_detail.html b/apps/users/templates/users/user_detail.html index eed7c8409..651763140 100644 --- a/apps/users/templates/users/user_detail.html +++ b/apps/users/templates/users/user_detail.html @@ -212,7 +212,7 @@
    - {% if user_object.is_current_org_admin or user_object.is_superuser %} + {% if user.is_current_org_admin or user.is_superuser %}
    {% trans 'User group' %} diff --git a/apps/users/urls/api_urls.py b/apps/users/urls/api_urls.py index e40f5d3a7..f0fe3db6f 100644 --- a/apps/users/urls/api_urls.py +++ b/apps/users/urls/api_urls.py @@ -20,9 +20,6 @@ router.register(r'users-groups-relations', api.UserUserGroupRelationViewSet, 'us urlpatterns = [ path('connection-token/', auth_api.UserConnectionTokenApi.as_view(), name='connection-token'), - path('auth/', auth_api.UserAuthApi.as_view(), name='user-auth'), - path('otp/auth/', auth_api.UserOtpAuthApi.as_view(), name='user-otp-auth'), - path('profile/', api.UserProfileApi.as_view(), name='user-profile'), path('otp/reset/', api.UserResetOTPApi.as_view(), name='my-otp-reset'), path('users//otp/reset/', api.UserResetOTPApi.as_view(), name='user-reset-otp'), diff --git a/apps/users/utils.py b/apps/users/utils.py index 9ab2c914e..a955350a9 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -218,9 +218,11 @@ def set_tmp_user_to_cache(request, user, ttl=3600): def redirect_user_first_login_or_index(request, redirect_field_name): if request.user.is_first_login: return reverse('users:user-first-login') - return request.POST.get( - redirect_field_name, - request.GET.get(redirect_field_name, reverse('index'))) + url_in_post = request.POST.get(redirect_field_name) + if url_in_post: + return url_in_post + url_in_get = request.GET.get(redirect_field_name, reverse('index')) + return url_in_get def generate_otp_uri(request, issuer="Jumpserver"): From 8de863525aade2275c29d226d3adb4e9d47f755a Mon Sep 17 00:00:00 2001 From: ibuler Date: Tue, 5 Nov 2019 19:05:29 +0800 Subject: [PATCH 17/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9=E6=B6=88?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/utils.py | 2 +- apps/locale/zh/LC_MESSAGES/django.mo | Bin 84603 -> 84606 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/authentication/utils.py b/apps/authentication/utils.py index f06b6ef15..1f190bef5 100644 --- a/apps/authentication/utils.py +++ b/apps/authentication/utils.py @@ -27,7 +27,7 @@ def check_user_valid(**kwargs): if user is None: return None, errors.reason_user_not_exist elif user.is_expired: - return None, errors.reason_password_expired + return None, errors.reason_user_inactive elif not user.is_active: return None, errors.reason_user_inactive elif user.password_has_expired: diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 39d51220e02eca0be696c263006d7160b39b4f2a..2f408e2249b2f0790ca19b07b33f8ecf4ecc2b17 100644 GIT binary patch delta 4005 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 ccK`1F@cwzW@LL From 6fbc4ce4fb95d3ff6c2c8bebfcbbf3c3d9adbcbc Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 6 Nov 2019 12:06:30 +0800 Subject: [PATCH 18/55] [Update] MFA --- apps/authentication/api/mfa.py | 5 +++-- apps/authentication/errors.py | 14 +++++++------- apps/authentication/serializers.py | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index 3d7cde6ad..a0bec8216 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -30,9 +30,10 @@ class MFAChallengeApi(AuthMixin, CreateAPIView): raise errors.MFAFailedError( username=user.username, request=self.request ) - + else: + self.request.session['auth_mfa'] = '1' except errors.AuthFailedError as e: - data = {"error": e.error, "msg": e.reason} + data = {"error": e.error, "msg": e.msg} raise ValidationError(data) def create(self, request, *args, **kwargs): diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index adafd05b1..0a5d42e77 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -128,12 +128,12 @@ class BlockLoginError(AuthFailedNeedBlockMixin, AuthFailedError): class SessionEmptyError(AuthFailedError): msg = session_empty_msg - error = 'session_empty_msg' + error = 'session_empty' class MFARequiredError(AuthFailedError): msg = mfa_required_msg - error = 'mfa_required_msg' + error = 'mfa_required' def as_data(self): return { @@ -146,12 +146,12 @@ class MFARequiredError(AuthFailedError): class LoginConfirmRequiredError(AuthFailedError): msg = login_confirm_required_msg - error = 'login_confirm_required_msg' + error = 'login_confirm_required' class LoginConfirmError(AuthFailedError): msg = login_confirm_wait_msg - error = 'login_confirm_wait_msg' + error = 'login_confirm_wait' def __init__(self, order_id, **kwargs): self.order_id = order_id @@ -167,14 +167,14 @@ class LoginConfirmError(AuthFailedError): class LoginConfirmWaitError(LoginConfirmError): msg = login_confirm_wait_msg - error = 'login_confirm_wait_msg' + error = 'login_confirm_wait' class LoginConfirmRejectedError(LoginConfirmError): msg = login_confirm_rejected_msg - error = 'login_confirm_rejected_msg' + error = 'login_confirm_rejected' class LoginConfirmOrderNotFound(LoginConfirmError): msg = login_confirm_order_not_found_msg - error = 'login_confirm_order_not_found_msg' + error = 'login_confirm_order_not_found' diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index 0a2f70dda..029c51c1e 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -60,7 +60,7 @@ class BearerTokenSerializer(serializers.Serializer): class MFAChallengeSerializer(serializers.Serializer): - auth_type = serializers.CharField(write_only=True, required=False, allow_blank=True) + type = serializers.CharField(write_only=True, required=False, allow_blank=True) code = serializers.CharField(write_only=True) def create(self, validated_data): From 12e1e559b53f7a52f8f60917fa6dec0bbdd215ce Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 6 Nov 2019 14:40:41 +0800 Subject: [PATCH 19/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/views/login.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 282ec8501..d409996c2 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -84,7 +84,7 @@ class UserLoginView(mixins.AuthMixin, FormView): def redirect_to_guard_view(self): guard_url = reverse('authentication:login-guard') args = self.request.META.get('QUERY_STRING', '') - if args and self.query_string: + if args: guard_url = "%s?%s" % (guard_url, args) return redirect(guard_url) From edce831e4689c208e21f76334e7b7d72b460a558 Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 6 Nov 2019 16:59:54 +0800 Subject: [PATCH 20/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/audits/signals_handler.py | 4 ++-- apps/authentication/api/login_confirm.py | 2 +- apps/authentication/api/token.py | 2 +- apps/authentication/errors.py | 10 +++++++--- apps/authentication/mixins.py | 1 + apps/orders/signals_handler.py | 1 - 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/audits/signals_handler.py b/apps/audits/signals_handler.py index d2a728f95..393f51a37 100644 --- a/apps/audits/signals_handler.py +++ b/apps/audits/signals_handler.py @@ -108,10 +108,10 @@ def generate_data(username, request): user_agent = request.META.get('HTTP_USER_AGENT', '') if isinstance(request, Request): - login_ip = request.data.get('remote_addr', None) + login_ip = request.data.get('remote_addr', '0.0.0.0') login_type = request.data.get('login_type', '') else: - login_ip = get_request_ip(request) + login_ip = get_request_ip(request) or '0.0.0.0' login_type = 'W' data = { diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py index 45faddac6..d2555eae4 100644 --- a/apps/authentication/api/login_confirm.py +++ b/apps/authentication/api/login_confirm.py @@ -50,7 +50,7 @@ class UserOrderAcceptAuthApi(APIView): elif order.status == order.STATUS_REJECTED: raise errors.LoginConfirmRejectedError(order_id) else: - return errors.LoginConfirmWaitError(order_id) + raise errors.LoginConfirmWaitError(order_id) except errors.AuthFailedError as e: data = e.as_data() return Response(data, status=400) diff --git a/apps/authentication/api/token.py b/apps/authentication/api/token.py index e0db1bcc3..7242cf9b5 100644 --- a/apps/authentication/api/token.py +++ b/apps/authentication/api/token.py @@ -36,4 +36,4 @@ class TokenCreateApi(AuthMixin, CreateAPIView): resp = super().create(request, *args, **kwargs) return resp except errors.AuthFailedError as e: - return Response(e.as_data(), status=401) + return Response(e.as_data(), status=400) diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 0a5d42e77..9a5957135 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -139,8 +139,10 @@ class MFARequiredError(AuthFailedError): return { 'error': self.error, 'msg': self.msg, - 'choices': ['otp'], - 'url': reverse('api-auth:mfa-challenge') + 'data': { + 'choices': ['otp'], + 'url': reverse('api-auth:mfa-challenge') + } } @@ -161,7 +163,9 @@ class LoginConfirmError(AuthFailedError): return { "error": self.error, "msg": self.msg, - "order_id": self.order_id + "data": { + "order_id": self.order_id + } } diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 02d728b2d..ec8965369 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -114,6 +114,7 @@ class AuthMixin: self.request.session['auth_password'] = '' self.request.session['auth_mfa'] = '' self.request.session['auth_confirm'] = '' + self.request.session['auth_order_id'] = '' def send_auth_signal(self, success=True, user=None, username='', reason=''): if success: diff --git a/apps/orders/signals_handler.py b/apps/orders/signals_handler.py index 60db0c5e5..9c25907f7 100644 --- a/apps/orders/signals_handler.py +++ b/apps/orders/signals_handler.py @@ -20,7 +20,6 @@ def on_login_confirm_order_assignees_set(sender, instance=None, action=None, if action == 'post_add': logger.debug('New order create, send mail: {}'.format(instance.id)) assignees = model.objects.filter(pk__in=pk_set) - print(assignees) send_login_confirm_order_mail_to_assignees(instance, assignees) From 08775551c21633fdc5dff96dc1fa43f8de1dc1ae Mon Sep 17 00:00:00 2001 From: ibuler Date: Thu, 7 Nov 2019 18:06:58 +0800 Subject: [PATCH 21/55] [Update] Rename app --- apps/authentication/api/login_confirm.py | 43 +++++++---- apps/authentication/errors.py | 18 ++--- apps/authentication/mixins.py | 26 +++---- apps/authentication/models.py | 16 ++--- apps/authentication/urls/api_urls.py | 2 +- apps/authentication/views/login.py | 28 ++++---- apps/jumpserver/settings.py | 2 +- apps/jumpserver/urls.py | 4 +- apps/orders/api.py | 39 ---------- apps/orders/apps.py | 9 --- apps/orders/serializers.py | 72 ------------------- apps/orders/signals_handler.py | 31 -------- apps/orders/urls/api_urls.py | 20 ------ apps/orders/urls/views_urls.py | 11 --- apps/orders/utils.py | 62 ---------------- apps/static/css/jumpserver.css | 4 ++ apps/templates/_filter_dropdown.html | 5 ++ apps/templates/_nav.html | 6 +- apps/{orders => tickets}/__init__.py | 0 apps/{orders => tickets}/admin.py | 0 apps/tickets/api/__init__.py | 4 ++ apps/tickets/api/base.py | 21 ++++++ apps/tickets/api/login_confirm.py | 39 ++++++++++ apps/tickets/apps.py | 5 ++ .../migrations/0001_initial.py | 30 +++++--- .../migrations/__init__.py | 0 apps/tickets/models/__init__.py | 4 ++ .../models.py => tickets/models/base.py} | 49 +++++++------ apps/tickets/models/login_confirm.py | 20 ++++++ apps/tickets/serializers/__init__.py | 4 ++ apps/tickets/serializers/base.py | 33 +++++++++ apps/tickets/serializers/login_confirm.py | 32 +++++++++ apps/tickets/signals_handler.py | 31 ++++++++ .../tickets/login_confirm_ticket_detail.html} | 4 +- .../tickets/login_confirm_ticket_list.html} | 49 +++++++------ apps/{orders => tickets}/tests.py | 0 apps/{orders => tickets}/urls/__init__.py | 0 apps/tickets/urls/api_urls.py | 23 ++++++ apps/tickets/urls/views_urls.py | 11 +++ apps/tickets/utils.py | 62 ++++++++++++++++ apps/{orders => tickets}/views.py | 20 +++--- apps/users/templates/users/user_list.html | 4 +- 42 files changed, 463 insertions(+), 380 deletions(-) delete mode 100644 apps/orders/api.py delete mode 100644 apps/orders/apps.py delete mode 100644 apps/orders/serializers.py delete mode 100644 apps/orders/signals_handler.py delete mode 100644 apps/orders/urls/api_urls.py delete mode 100644 apps/orders/urls/views_urls.py delete mode 100644 apps/orders/utils.py rename apps/{orders => tickets}/__init__.py (100%) rename apps/{orders => tickets}/admin.py (100%) create mode 100644 apps/tickets/api/__init__.py create mode 100644 apps/tickets/api/base.py create mode 100644 apps/tickets/api/login_confirm.py create mode 100644 apps/tickets/apps.py rename apps/{orders => tickets}/migrations/0001_initial.py (68%) rename apps/{orders => tickets}/migrations/__init__.py (100%) create mode 100644 apps/tickets/models/__init__.py rename apps/{orders/models.py => tickets/models/base.py} (73%) create mode 100644 apps/tickets/models/login_confirm.py create mode 100644 apps/tickets/serializers/__init__.py create mode 100644 apps/tickets/serializers/base.py create mode 100644 apps/tickets/serializers/login_confirm.py create mode 100644 apps/tickets/signals_handler.py rename apps/{orders/templates/orders/login_confirm_order_detail.html => tickets/templates/tickets/login_confirm_ticket_detail.html} (98%) rename apps/{orders/templates/orders/login_confirm_order_list.html => tickets/templates/tickets/login_confirm_ticket_list.html} (72%) rename apps/{orders => tickets}/tests.py (100%) rename apps/{orders => tickets}/urls/__init__.py (100%) create mode 100644 apps/tickets/urls/api_urls.py create mode 100644 apps/tickets/urls/views_urls.py create mode 100644 apps/tickets/utils.py rename apps/{orders => tickets}/views.py (51%) diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py index d2555eae4..62350e5c9 100644 --- a/apps/authentication/api/login_confirm.py +++ b/apps/authentication/api/login_confirm.py @@ -11,7 +11,7 @@ from ..models import LoginConfirmSetting from ..serializers import LoginConfirmSettingSerializer from .. import errors -__all__ = ['LoginConfirmSettingUpdateApi', 'UserOrderAcceptAuthApi'] +__all__ = ['LoginConfirmSettingUpdateApi', 'UserTicketAcceptAuthApi'] logger = get_logger(__name__) @@ -30,27 +30,42 @@ class LoginConfirmSettingUpdateApi(UpdateAPIView): return s -class UserOrderAcceptAuthApi(APIView): +class UserTicketAcceptAuthApi(APIView): permission_classes = () def get(self, request, *args, **kwargs): - from orders.models import LoginConfirmOrder - order_id = self.request.session.get("auth_order_id") - logger.debug('Login confirm order id: {}'.format(order_id)) - if not order_id: - order = None + from tickets.models import LoginConfirmTicket + ticket_id = self.request.session.get("auth_ticket_id") + logger.debug('Login confirm ticket id: {}'.format(ticket_id)) + if not ticket_id: + ticket = None else: - order = get_object_or_none(LoginConfirmOrder, pk=order_id) + ticket = get_object_or_none(LoginConfirmTicket, pk=ticket_id) try: - if not order: - raise errors.LoginConfirmOrderNotFound(order_id) - if order.status == order.STATUS_ACCEPTED: + if not ticket: + raise errors.LoginConfirmTicketNotFound(ticket_id) + if ticket.action == LoginConfirmTicket.ACTION_APPROVE: self.request.session["auth_confirm"] = "1" return Response({"msg": "ok"}) - elif order.status == order.STATUS_REJECTED: - raise errors.LoginConfirmRejectedError(order_id) + elif ticket.action == LoginConfirmTicket.ACTION_REJECT: + raise errors.LoginConfirmRejectedError(ticket_id) else: - raise errors.LoginConfirmWaitError(order_id) + raise errors.LoginConfirmWaitError(ticket_id) except errors.AuthFailedError as e: data = e.as_data() return Response(data, status=400) + + +class UserTicketCancelAuthApi(APIView): + permission_classes = () + + def get(self, request, *args, **kwargs): + from tickets.models import LoginConfirmTicket + ticket_id = self.request.session.get("auth_ticket_id") + logger.debug('Login confirm ticket id: {}'.format(ticket_id)) + if not ticket_id: + ticket = None + else: + ticket = get_object_or_none(LoginConfirmTicket, pk=ticket_id) + if not ticket: + ticket.status = "close" diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 9a5957135..a6a73240f 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -47,9 +47,9 @@ mfa_failed_msg = _("MFA code invalid, or ntp sync server time") mfa_required_msg = _("MFA required") login_confirm_required_msg = _("Login confirm required") -login_confirm_wait_msg = _("Wait login confirm order for accept") -login_confirm_rejected_msg = _("Login confirm order was rejected") -login_confirm_order_not_found_msg = _("Order not found") +login_confirm_wait_msg = _("Wait login confirm ticket for accept") +login_confirm_rejected_msg = _("Login confirm ticket was rejected") +login_confirm_ticket_not_found_msg = _("Ticket not found") class AuthFailedNeedLogMixin: @@ -155,8 +155,8 @@ class LoginConfirmError(AuthFailedError): msg = login_confirm_wait_msg error = 'login_confirm_wait' - def __init__(self, order_id, **kwargs): - self.order_id = order_id + def __init__(self, ticket_id, **kwargs): + self.ticket_id = ticket_id super().__init__(**kwargs) def as_data(self): @@ -164,7 +164,7 @@ class LoginConfirmError(AuthFailedError): "error": self.error, "msg": self.msg, "data": { - "order_id": self.order_id + "ticket_id": self.ticket_id } } @@ -179,6 +179,6 @@ class LoginConfirmRejectedError(LoginConfirmError): error = 'login_confirm_rejected' -class LoginConfirmOrderNotFound(LoginConfirmError): - msg = login_confirm_order_not_found_msg - error = 'login_confirm_order_not_found' +class LoginConfirmTicketNotFound(LoginConfirmError): + msg = login_confirm_ticket_not_found_msg + error = 'login_confirm_ticket_not_found' diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index ec8965369..b33d0fdae 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -91,30 +91,30 @@ class AuthMixin: raise errors.MFAFailedError(username=user.username, request=self.request) def check_user_login_confirm_if_need(self, user): - from orders.models import LoginConfirmOrder + from tickets.models import LoginConfirmTicket confirm_setting = user.get_login_confirm_setting() if self.request.session.get('auth_confirm') or not confirm_setting: return - order = None - if self.request.session.get('auth_order_id'): - order_id = self.request.session['auth_order_id'] - order = get_object_or_none(LoginConfirmOrder, pk=order_id) - if not order: - order = confirm_setting.create_confirm_order(self.request) - self.request.session['auth_order_id'] = str(order.id) + ticket = None + if self.request.session.get('auth_ticket_id'): + ticket_id = self.request.session['auth_ticket_id'] + ticket = get_object_or_none(LoginConfirmTicket, pk=ticket_id) + if not ticket: + ticket = confirm_setting.create_confirm_ticket(self.request) + self.request.session['auth_ticket_id'] = str(ticket.id) - if order.status == "accepted": + if ticket.status == "accepted": return - elif order.status == "rejected": - raise errors.LoginConfirmRejectedError(order.id) + elif ticket.status == "rejected": + raise errors.LoginConfirmRejectedError(ticket.id) else: - raise errors.LoginConfirmWaitError(order.id) + raise errors.LoginConfirmWaitError(ticket.id) def clear_auth_mark(self): self.request.session['auth_password'] = '' self.request.session['auth_mfa'] = '' self.request.session['auth_confirm'] = '' - self.request.session['auth_order_id'] = '' + self.request.session['auth_ticket_id'] = '' def send_auth_signal(self, success=True, user=None, username='', reason=''): if success: diff --git a/apps/authentication/models.py b/apps/authentication/models.py index 4f0e06fb6..913e04f73 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -48,8 +48,8 @@ class LoginConfirmSetting(CommonModelMixin): def get_user_confirm_setting(cls, user): return get_object_or_none(cls, user=user) - def create_confirm_order(self, request=None): - from orders.models import LoginConfirmOrder + def create_confirm_ticket(self, request=None): + from tickets.models import LoginConfirmTicket title = _('User login confirm: {}').format(self.user) if request: remote_addr = get_request_ip(request) @@ -58,20 +58,20 @@ class LoginConfirmSetting(CommonModelMixin): self.user, remote_addr, city, timezone.now() ) else: - city = '' - remote_addr = '' + city = 'Localhost' + remote_addr = '127.0.0.1' body = '' reviewer = self.reviewers.all() reviewer_names = ','.join([u.name for u in reviewer]) - order = LoginConfirmOrder.objects.create( + ticket = LoginConfirmTicket.objects.create( user=self.user, user_display=str(self.user), title=title, body=body, city=city, ip=remote_addr, assignees_display=reviewer_names, - type=LoginConfirmOrder.TYPE_LOGIN_CONFIRM, + type=LoginConfirmTicket.TYPE_LOGIN_CONFIRM, ) - order.assignees.set(reviewer) - return order + ticket.assignees.set(reviewer) + return ticket def __str__(self): return '{} confirm'.format(self.user.username) diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index 57e238192..a7e15053b 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -18,7 +18,7 @@ urlpatterns = [ path('connection-token/', api.UserConnectionTokenApi.as_view(), name='connection-token'), path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'), - path('order/auth/', api.UserOrderAcceptAuthApi.as_view(), name='user-order-auth'), + path('order/auth/', api.UserTicketAcceptAuthApi.as_view(), name='user-order-auth'), path('login-confirm-settings//', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update') ] diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index d409996c2..2bfa3d894 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -126,8 +126,8 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView): return self.format_redirect_url(self.login_otp_url) confirm_setting = user.get_login_confirm_setting() if confirm_setting and not self.request.session.get('auth_confirm'): - order = confirm_setting.create_confirm_order(self.request) - self.request.session['auth_order_id'] = str(order.id) + ticket = confirm_setting.create_confirm_ticket(self.request) + self.request.session['auth_ticket_id'] = str(ticket.id) url = self.format_redirect_url(self.login_confirm_url) return url self.login_success(user) @@ -159,26 +159,26 @@ class UserLoginWaitConfirmView(TemplateView): template_name = 'authentication/login_wait_confirm.html' def get_context_data(self, **kwargs): - from orders.models import LoginConfirmOrder - order_id = self.request.session.get("auth_order_id") - if not order_id: - order = None + from tickets.models import LoginConfirmTicket + ticket_id = self.request.session.get("auth_ticket_id") + if not ticket_id: + ticket = None else: - order = get_object_or_none(LoginConfirmOrder, pk=order_id) + ticket = get_object_or_none(LoginConfirmTicket, pk=ticket_id) context = super().get_context_data(**kwargs) - if order: - order_detail_url = reverse('orders:login-confirm-order-detail', kwargs={'pk': order_id}) - timestamp_created = datetime.datetime.timestamp(order.date_created) + if ticket: + ticket_detail_url = reverse('tickets:login-confirm-ticket-detail', kwargs={'pk': ticket_id}) + timestamp_created = datetime.datetime.timestamp(ticket.date_created) msg = _("""Wait for {} confirm, You also can copy link to her/him
    - Don't close this page""").format(order.assignees_display) + Don't close this page""").format(ticket.assignees_display) else: timestamp_created = 0 - order_detail_url = '' - msg = _("No order found") + ticket_detail_url = '' + msg = _("No ticket found") context.update({ "msg": msg, "timestamp": timestamp_created, - "order_detail_url": order_detail_url + "ticket_detail_url": ticket_detail_url }) return context diff --git a/apps/jumpserver/settings.py b/apps/jumpserver/settings.py index 6f36f529a..3eea3c78a 100644 --- a/apps/jumpserver/settings.py +++ b/apps/jumpserver/settings.py @@ -71,7 +71,7 @@ INSTALLED_APPS = [ 'audits.apps.AuditsConfig', 'authentication.apps.AuthenticationConfig', # authentication 'applications.apps.ApplicationsConfig', - 'orders.apps.OrdersConfig', + 'tickets.apps.TicketsConfig', 'rest_framework', 'rest_framework_swagger', 'drf_yasg', diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index b9bdb697f..4d5e96671 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -24,7 +24,7 @@ api_v1 = [ path('authentication/', include('authentication.urls.api_urls', namespace='api-auth')), path('common/', include('common.urls.api_urls', namespace='api-common')), path('applications/', include('applications.urls.api_urls', namespace='api-applications')), - path('orders/', include('orders.urls.api_urls', namespace='api-orders')), + path('tickets/', include('tickets.urls.api_urls', namespace='api-tickets')), ] api_v2 = [ @@ -43,7 +43,7 @@ app_view_patterns = [ path('orgs/', include('orgs.urls.views_urls', namespace='orgs')), path('auth/', include('authentication.urls.view_urls'), name='auth'), path('applications/', include('applications.urls.views_urls', namespace='applications')), - path('orders/', include('orders.urls.views_urls', namespace='orders')), + path('tickets/', include('tickets.urls.views_urls', namespace='tickets')), re_path(r'flower/(?P.*)', celery_flower_view, name='flower-view'), ] diff --git a/apps/orders/api.py b/apps/orders/api.py deleted file mode 100644 index aec04ad46..000000000 --- a/apps/orders/api.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -# -from rest_framework import viewsets, generics -from django.shortcuts import get_object_or_404 - -from common.permissions import IsValidUser -from common.mixins import CommonApiMixin -from . import serializers -from .models import LoginConfirmOrder - - -class LoginConfirmOrderViewSet(CommonApiMixin, viewsets.ModelViewSet): - serializer_class = serializers.LoginConfirmOrderSerializer - permission_classes = (IsValidUser,) - filter_fields = ['status', 'title'] - search_fields = ['user_display', 'title', 'ip', 'city'] - - def get_queryset(self): - queryset = LoginConfirmOrder.objects.all()\ - .filter(assignees=self.request.user) - return queryset - - -class LoginConfirmOrderCreateActionApi(generics.CreateAPIView): - permission_classes = (IsValidUser,) - serializer_class = serializers.LoginConfirmOrderActionSerializer - - def get_order(self): - order_id = self.kwargs.get('pk') - queryset = LoginConfirmOrder.objects.all()\ - .filter(assignees=self.request.user) - order = get_object_or_404(queryset, id=order_id) - return order - - def get_serializer_context(self): - context = super().get_serializer_context() - order = self.get_order() - context['order'] = order - return context diff --git a/apps/orders/apps.py b/apps/orders/apps.py deleted file mode 100644 index 3e58af6ea..000000000 --- a/apps/orders/apps.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.apps import AppConfig - - -class OrdersConfig(AppConfig): - name = 'orders' - - def ready(self): - from . import signals_handler - return super().ready() diff --git a/apps/orders/serializers.py b/apps/orders/serializers.py deleted file mode 100644 index d74e33208..000000000 --- a/apps/orders/serializers.py +++ /dev/null @@ -1,72 +0,0 @@ -# -*- coding: utf-8 -*- -# -from rest_framework import serializers -from django.utils.translation import ugettext_lazy as _ - -from .models import LoginConfirmOrder, Comment - - -class LoginConfirmOrderSerializer(serializers.ModelSerializer): - class Meta: - model = LoginConfirmOrder - fields = [ - 'id', 'user', 'user_display', 'title', 'body', - 'ip', 'city', 'assignees', 'assignees_display', - 'type', 'status', 'date_created', 'date_updated', - ] - - -class LoginConfirmOrderActionSerializer(serializers.Serializer): - ACTION_CHOICES = ( - ('accept', _('Accept')), - ('reject', _('Reject')), - ('comment', _('Comment')) - ) - action = serializers.ChoiceField(choices=ACTION_CHOICES) - comment = serializers.CharField(allow_blank=True) - - def update(self, instance, validated_data): - pass - - def create_comments(self, order, user, validated_data): - comment_data = validated_data.get('comment') - action = validated_data.get('action') - comments_data = [] - if comment_data: - comments_data.append(comment_data) - Comment.objects.create( - order_id=order.id, body=comment_data, user=user, - user_display=str(user) - ) - if action != "comment": - action_display = dict(self.ACTION_CHOICES).get(action) - comment_data = '{} {} {}'.format(user, action_display, _("this order")) - comments_data.append(comment_data) - comments = [ - Comment(order_id=order.id, body=data, user=user, user_display=str(user)) - for data in comments_data - ] - Comment.objects.bulk_create(comments) - - @staticmethod - def perform_action(order, user, validated_data): - action = validated_data.get('action') - if action == "accept": - status = "accepted" - elif action == "reject": - status = "rejected" - else: - status = None - - if status: - order.status = status - order.assignee = user - order.assignee_display = str(user) - order.save() - - def create(self, validated_data): - order = self.context['order'] - user = self.context['request'].user - self.create_comments(order, user, validated_data) - self.perform_action(order, user, validated_data) - return validated_data diff --git a/apps/orders/signals_handler.py b/apps/orders/signals_handler.py deleted file mode 100644 index 9c25907f7..000000000 --- a/apps/orders/signals_handler.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -# -from django.dispatch import receiver -from django.db.models.signals import m2m_changed, post_save - -from common.utils import get_logger -from .models import LoginConfirmOrder -from .utils import ( - send_login_confirm_order_mail_to_assignees, - send_login_confirm_action_mail_to_user -) - - -logger = get_logger(__name__) - - -@receiver(m2m_changed, sender=LoginConfirmOrder.assignees.through) -def on_login_confirm_order_assignees_set(sender, instance=None, action=None, - model=None, pk_set=None, **kwargs): - if action == 'post_add': - logger.debug('New order create, send mail: {}'.format(instance.id)) - assignees = model.objects.filter(pk__in=pk_set) - send_login_confirm_order_mail_to_assignees(instance, assignees) - - -@receiver(post_save, sender=LoginConfirmOrder) -def on_login_confirm_order_status_change(sender, instance=None, created=False, **kwargs): - if created or instance.status == "pending": - return - logger.debug('Order changed, send mail: {}'.format(instance.id)) - send_login_confirm_action_mail_to_user(instance) diff --git a/apps/orders/urls/api_urls.py b/apps/orders/urls/api_urls.py deleted file mode 100644 index 81828d3fe..000000000 --- a/apps/orders/urls/api_urls.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# -from django.urls import path -from rest_framework.routers import DefaultRouter - -from .. import api - -app_name = 'orders' -router = DefaultRouter() - -router.register('login-confirm-orders', api.LoginConfirmOrderViewSet, 'login-confirm-order') - -urlpatterns = [ - path('login-confirm-order//actions/', - api.LoginConfirmOrderCreateActionApi.as_view(), - name='login-confirm-order-create-action' - ), -] - -urlpatterns += router.urls diff --git a/apps/orders/urls/views_urls.py b/apps/orders/urls/views_urls.py deleted file mode 100644 index f4fe0ba05..000000000 --- a/apps/orders/urls/views_urls.py +++ /dev/null @@ -1,11 +0,0 @@ -# -*- coding: utf-8 -*- -# -from django.urls import path -from .. import views - -app_name = 'orders' - -urlpatterns = [ - path('login-confirm-orders/', views.LoginConfirmOrderListView.as_view(), name='login-confirm-order-list'), - path('login-confirm-orders//', views.LoginConfirmOrderDetailView.as_view(), name='login-confirm-order-detail') -] diff --git a/apps/orders/utils.py b/apps/orders/utils.py deleted file mode 100644 index 6fd3d965d..000000000 --- a/apps/orders/utils.py +++ /dev/null @@ -1,62 +0,0 @@ -# -*- coding: utf-8 -*- -# -from django.conf import settings -from django.utils.translation import ugettext as _ - -from common.utils import get_logger, reverse -from common.tasks import send_mail_async - -logger = get_logger(__name__) - - -def send_login_confirm_order_mail_to_assignees(order, assignees): - recipient_list = [user.email for user in assignees] - user = order.user - if not recipient_list: - logger.error("Order not has assignees: {}".format(order.id)) - return - subject = '{}: {}'.format(_("New order"), order.title) - detail_url = reverse('orders:login-confirm-order-detail', - kwargs={'pk': order.id}, external=True) - message = _(""" -
    -

    Your has a new order

    -
    - Title: {order.title} -
    - User: {user} -
    - Assignees: {order.assignees_display} -
    - City: {order.city} -
    - IP: {order.ip} -
    - click here to review -
    -
    - """).format(order=order, user=user, url=detail_url) - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - -def send_login_confirm_action_mail_to_user(order): - if not order.user: - logger.error("Order not has user: {}".format(order.id)) - return - user = order.user - recipient_list = [user.email] - subject = '{}: {}'.format(_("Order has been reply"), order.title) - message = _(""" -
    -

    Your order has been replay

    -
    - Title: {order.title} -
    - Assignee: {order.assignee_display} -
    - Status: {order.status_display} -
    -
    -
    - """).format(order=order) - send_mail_async.delay(subject, message, recipient_list, html_message=message) diff --git a/apps/static/css/jumpserver.css b/apps/static/css/jumpserver.css index ffcb87d33..02b59aae6 100644 --- a/apps/static/css/jumpserver.css +++ b/apps/static/css/jumpserver.css @@ -554,3 +554,7 @@ span.select2-selection__placeholder { height: 22px; max-width: inherit; } + +table.table-striped.table-bordered { + width: 100% !important; +} diff --git a/apps/templates/_filter_dropdown.html b/apps/templates/_filter_dropdown.html index e99ca79ef..9237df0d5 100644 --- a/apps/templates/_filter_dropdown.html +++ b/apps/templates/_filter_dropdown.html @@ -1,3 +1,8 @@ + diff --git a/apps/templates/_nav.html b/apps/templates/_nav.html index d23f549ca..2aecd2fd6 100644 --- a/apps/templates/_nav.html +++ b/apps/templates/_nav.html @@ -122,12 +122,12 @@ {% endif %} {% if request.user.can_admin_current_org and LOGIN_CONFIRM_ENABLE %} -
  • +
  • - {% trans 'Orders' %} + {% trans 'Tickets' %}
  • {% endif %} diff --git a/apps/orders/__init__.py b/apps/tickets/__init__.py similarity index 100% rename from apps/orders/__init__.py rename to apps/tickets/__init__.py diff --git a/apps/orders/admin.py b/apps/tickets/admin.py similarity index 100% rename from apps/orders/admin.py rename to apps/tickets/admin.py diff --git a/apps/tickets/api/__init__.py b/apps/tickets/api/__init__.py new file mode 100644 index 000000000..99396f9f3 --- /dev/null +++ b/apps/tickets/api/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# +from .base import * +from .login_confirm import * diff --git a/apps/tickets/api/base.py b/apps/tickets/api/base.py new file mode 100644 index 000000000..a029d8fab --- /dev/null +++ b/apps/tickets/api/base.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# +from rest_framework import viewsets, generics + +from .. import serializers, models + + +class TicketViewSet(viewsets.ModelViewSet): + serializer_class = serializers.TicketSerializer + + def get_queryset(self): + queryset = models.Ticket.objects.all().none() + return queryset + + +class CommentViewSet(viewsets.ModelViewSet): + serializer_class = serializers.CommentSerializer + + def get_queryset(self): + queryset = models.Comment.objects.none() + return queryset diff --git a/apps/tickets/api/login_confirm.py b/apps/tickets/api/login_confirm.py new file mode 100644 index 000000000..c990ee339 --- /dev/null +++ b/apps/tickets/api/login_confirm.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# +from rest_framework import viewsets, generics +from django.shortcuts import get_object_or_404 + +from common.permissions import IsValidUser +from common.mixins import CommonApiMixin +from .. import serializers +from ..models import LoginConfirmTicket + + +class LoginConfirmTicketViewSet(CommonApiMixin, viewsets.ModelViewSet): + serializer_class = serializers.LoginConfirmTicketSerializer + permission_classes = (IsValidUser,) + filter_fields = ['status', 'title'] + search_fields = ['user_display', 'title', 'ip', 'city'] + + def get_queryset(self): + queryset = LoginConfirmTicket.objects.all()\ + .filter(assignees=self.request.user) + return queryset + + +class LoginConfirmTicketsCreateActionApi(generics.CreateAPIView): + permission_classes = (IsValidUser,) + serializer_class = serializers.LoginConfirmTicketActionSerializer + + def get_ticket(self): + ticket_id = self.kwargs.get('pk') + queryset = LoginConfirmTicket.objects.all()\ + .filter(assignees=self.request.user) + ticket = get_object_or_404(queryset, id=ticket_id) + return ticket + + def get_serializer_context(self): + context = super().get_serializer_context() + ticket = self.get_ticket() + context['ticket'] = ticket + return context diff --git a/apps/tickets/apps.py b/apps/tickets/apps.py new file mode 100644 index 000000000..3ea742ac3 --- /dev/null +++ b/apps/tickets/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TicketsConfig(AppConfig): + name = 'tickets' diff --git a/apps/orders/migrations/0001_initial.py b/apps/tickets/migrations/0001_initial.py similarity index 68% rename from apps/orders/migrations/0001_initial.py rename to apps/tickets/migrations/0001_initial.py index 9b1099965..e86fc44ad 100644 --- a/apps/orders/migrations/0001_initial.py +++ b/apps/tickets/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 2.2.5 on 2019-10-31 10:23 +# Generated by Django 2.2.5 on 2019-11-07 08:02 from django.conf import settings from django.db import migrations, models @@ -16,7 +16,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='LoginConfirmOrder', + name='Ticket', fields=[ ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), @@ -27,18 +27,28 @@ class Migration(migrations.Migration): ('body', models.TextField(verbose_name='Body')), ('assignee_display', models.CharField(blank=True, max_length=128, null=True, verbose_name='Assignee display name')), ('assignees_display', models.CharField(blank=True, max_length=128, verbose_name='Assignees display name')), - ('type', models.CharField(choices=[('login_confirm', 'Login confirm')], max_length=16, verbose_name='Type')), - ('status', models.CharField(choices=[('accepted', 'Accepted'), ('rejected', 'Rejected'), ('pending', 'Pending')], default='pending', max_length=16)), - ('ip', models.GenericIPAddressField(blank=True, null=True)), - ('city', models.CharField(blank=True, default='', max_length=16)), - ('assignee', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='loginconfirmorder_handled', to=settings.AUTH_USER_MODEL, verbose_name='Assignee')), - ('assignees', models.ManyToManyField(related_name='loginconfirmorder_assigned', to=settings.AUTH_USER_MODEL, verbose_name='Assignees')), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='loginconfirmorder_requested', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ('type', models.CharField(default='general', max_length=16, verbose_name='Type')), + ('status', models.CharField(choices=[('open', 'Open'), ('closed', 'Closed')], default='open', max_length=16)), + ('assignee', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_handled', to=settings.AUTH_USER_MODEL, verbose_name='Assignee')), + ('assignees', models.ManyToManyField(related_name='ticket_assigned', to=settings.AUTH_USER_MODEL, verbose_name='Assignees')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_requested', to=settings.AUTH_USER_MODEL, verbose_name='User')), ], options={ 'ordering': ('-date_created',), + }, + ), + migrations.CreateModel( + name='LoginConfirmTicket', + fields=[ + ('ticket_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tickets.Ticket')), + ('ip', models.GenericIPAddressField(blank=True, null=True)), + ('city', models.CharField(blank=True, default='', max_length=16)), + ('action', models.CharField(blank=True, choices=[('approve', 'Approve'), ('reject', 'Reject')], default='', max_length=16)), + ], + options={ 'abstract': False, }, + bases=('tickets.ticket',), ), migrations.CreateModel( name='Comment', @@ -47,9 +57,9 @@ class Migration(migrations.Migration): ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), - ('order_id', models.UUIDField()), ('user_display', models.CharField(max_length=128, verbose_name='User display name')), ('body', models.TextField(verbose_name='Body')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.Ticket')), ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comments', to=settings.AUTH_USER_MODEL, verbose_name='User')), ], options={ diff --git a/apps/orders/migrations/__init__.py b/apps/tickets/migrations/__init__.py similarity index 100% rename from apps/orders/migrations/__init__.py rename to apps/tickets/migrations/__init__.py diff --git a/apps/tickets/models/__init__.py b/apps/tickets/models/__init__.py new file mode 100644 index 000000000..99396f9f3 --- /dev/null +++ b/apps/tickets/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# +from .base import * +from .login_confirm import * diff --git a/apps/orders/models.py b/apps/tickets/models/base.py similarity index 73% rename from apps/orders/models.py rename to apps/tickets/models/base.py index 3ed12c8c8..3fcb702cf 100644 --- a/apps/orders/models.py +++ b/apps/tickets/models/base.py @@ -1,33 +1,26 @@ +# -*- coding: utf-8 -*- +# + from django.db import models from django.utils.translation import ugettext_lazy as _ from common.mixins.models import CommonModelMixin -__all__ = ['LoginConfirmOrder', 'Comment'] +__all__ = ['Ticket', 'Comment'] -class Comment(CommonModelMixin): - order_id = models.UUIDField() - user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, verbose_name=_("User"), related_name='comments') - user_display = models.CharField(max_length=128, verbose_name=_("User display name")) - body = models.TextField(verbose_name=_("Body")) - - class Meta: - ordering = ('date_created', ) - - -class BaseOrder(CommonModelMixin): - STATUS_ACCEPTED = 'accepted' - STATUS_REJECTED = 'rejected' - STATUS_PENDING = 'pending' +class Ticket(CommonModelMixin): + STATUS_OPEN = 'open' + STATUS_CLOSED = 'closed' STATUS_CHOICES = ( - (STATUS_ACCEPTED, _("Accepted")), - (STATUS_REJECTED, _("Rejected")), - (STATUS_PENDING, _("Pending")) + (STATUS_OPEN, _("Open")), + (STATUS_CLOSED, _("Closed")) ) + TYPE_GENERAL = 'general' TYPE_LOGIN_CONFIRM = 'login_confirm' TYPE_CHOICES = ( - (TYPE_LOGIN_CONFIRM, 'Login confirm'), + (TYPE_GENERAL, _("General")), + (TYPE_LOGIN_CONFIRM, _("Login confirm")) ) user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='%(class)s_requested', verbose_name=_("User")) user_display = models.CharField(max_length=128, verbose_name=_("User display name")) @@ -38,8 +31,8 @@ class BaseOrder(CommonModelMixin): assignee_display = models.CharField(max_length=128, blank=True, null=True, verbose_name=_("Assignee display name")) assignees = models.ManyToManyField('users.User', related_name='%(class)s_assigned', verbose_name=_("Assignees")) assignees_display = models.CharField(max_length=128, verbose_name=_("Assignees display name"), blank=True) - type = models.CharField(choices=TYPE_CHOICES, max_length=16, verbose_name=_('Type')) - status = models.CharField(choices=STATUS_CHOICES, max_length=16, default='pending') + type = models.CharField(max_length=16, default='general', verbose_name=_("Type")) + status = models.CharField(choices=STATUS_CHOICES, max_length=16, default='open') def __str__(self): return '{}: {}'.format(self.user_display, self.title) @@ -57,10 +50,16 @@ class BaseOrder(CommonModelMixin): return self.get_status_display() class Meta: - abstract = True ordering = ('-date_created',) -class LoginConfirmOrder(BaseOrder): - ip = models.GenericIPAddressField(blank=True, null=True) - city = models.CharField(max_length=16, blank=True, default='') +class Comment(CommonModelMixin): + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE) + user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, verbose_name=_("User"), related_name='comments') + user_display = models.CharField(max_length=128, verbose_name=_("User display name")) + body = models.TextField(verbose_name=_("Body")) + + class Meta: + ordering = ('date_created', ) + + diff --git a/apps/tickets/models/login_confirm.py b/apps/tickets/models/login_confirm.py new file mode 100644 index 000000000..0781d20be --- /dev/null +++ b/apps/tickets/models/login_confirm.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from .base import Ticket + +__all__ = ['LoginConfirmTicket'] + + +class LoginConfirmTicket(Ticket): + ACTION_APPROVE = 'approve' + ACTION_REJECT = 'reject' + ACTION_CHOICES = ( + (ACTION_APPROVE, _('Approve')), + (ACTION_REJECT, _('Reject')), + ) + ip = models.GenericIPAddressField(blank=True, null=True) + city = models.CharField(max_length=16, blank=True, default='') + action = models.CharField(choices=ACTION_CHOICES, max_length=16, default='', blank=True) diff --git a/apps/tickets/serializers/__init__.py b/apps/tickets/serializers/__init__.py new file mode 100644 index 000000000..99396f9f3 --- /dev/null +++ b/apps/tickets/serializers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# +from .base import * +from .login_confirm import * diff --git a/apps/tickets/serializers/base.py b/apps/tickets/serializers/base.py new file mode 100644 index 000000000..eb4040f59 --- /dev/null +++ b/apps/tickets/serializers/base.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# +from rest_framework import serializers + +from .. import models + +__all__ = ['TicketSerializer', 'CommentSerializer'] + + +class TicketSerializer(serializers.ModelSerializer): + class Meta: + model = models.Ticket + fields = [ + 'id', 'user', 'user_display', 'title', 'body', + 'assignees', 'assignees_display', + 'status', 'date_created', 'date_updated', + ] + read_only_fields = [ + 'user_display', 'assignees_display', + 'date_created', 'date_updated', + ] + + +class CommentSerializer(serializers.ModelSerializer): + class Meta: + model = models.Comment + fields = [ + 'id', 'ticket', 'body', 'user', 'user_display', + 'date_created', 'date_updated' + ] + read_only_fields = [ + 'user_display', 'date_created', 'date_updated' + ] diff --git a/apps/tickets/serializers/login_confirm.py b/apps/tickets/serializers/login_confirm.py new file mode 100644 index 000000000..4649294ec --- /dev/null +++ b/apps/tickets/serializers/login_confirm.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# +from rest_framework import serializers + +from .base import TicketSerializer +from ..models import LoginConfirmTicket + + +__all__ = ['LoginConfirmTicketSerializer', 'LoginConfirmTicketActionSerializer'] + + +class LoginConfirmTicketSerializer(serializers.ModelSerializer): + class Meta: + model = LoginConfirmTicket + fields = TicketSerializer.Meta.fields + [ + 'ip', 'city', 'action' + ] + read_only_fields = TicketSerializer.Meta.read_only_fields + + +class LoginConfirmTicketActionSerializer(serializers.ModelSerializer): + comment = serializers.CharField(allow_blank=True) + + class Meta: + model = LoginConfirmTicket + fields = ['action', 'comment'] + + def update(self, instance, validated_data): + pass + + def create(self, validated_data): + pass diff --git a/apps/tickets/signals_handler.py b/apps/tickets/signals_handler.py new file mode 100644 index 000000000..a1b9dbbec --- /dev/null +++ b/apps/tickets/signals_handler.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# +from django.dispatch import receiver +from django.db.models.signals import m2m_changed, post_save + +from common.utils import get_logger +from .models import LoginConfirmTicket +from .utils import ( + send_login_confirm_ticket_mail_to_assignees, + send_login_confirm_action_mail_to_user +) + + +logger = get_logger(__name__) + + +@receiver(m2m_changed, sender=LoginConfirmTicket.assignees.through) +def on_login_confirm_ticket_assignees_set(sender, instance=None, action=None, + model=None, pk_set=None, **kwargs): + if action == 'post_add': + logger.debug('New ticket create, send mail: {}'.format(instance.id)) + assignees = model.objects.filter(pk__in=pk_set) + send_login_confirm_ticket_mail_to_assignees(instance, assignees) + + +@receiver(post_save, sender=LoginConfirmTicket) +def on_login_confirm_ticket_status_change(sender, instance=None, created=False, **kwargs): + if created or instance.status == "pending": + return + logger.debug('Ticket changed, send mail: {}'.format(instance.id)) + send_login_confirm_action_mail_to_user(instance) diff --git a/apps/orders/templates/orders/login_confirm_order_detail.html b/apps/tickets/templates/tickets/login_confirm_ticket_detail.html similarity index 98% rename from apps/orders/templates/orders/login_confirm_order_detail.html rename to apps/tickets/templates/tickets/login_confirm_ticket_detail.html index 55a86e009..2b43da756 100644 --- a/apps/orders/templates/orders/login_confirm_order_detail.html +++ b/apps/tickets/templates/tickets/login_confirm_ticket_detail.html @@ -112,9 +112,9 @@ {% endblock %} {% block custom_foot_js %} {% endblock %} + + + diff --git a/apps/tickets/templates/tickets/login_confirm_ticket_list.html b/apps/tickets/templates/tickets/login_confirm_ticket_list.html index dd5129ea9..51a14b3ae 100644 --- a/apps/tickets/templates/tickets/login_confirm_ticket_list.html +++ b/apps/tickets/templates/tickets/login_confirm_ticket_list.html @@ -13,7 +13,6 @@ {% trans 'Title' %} {% trans 'User' %} - {% trans 'IP' %} {% trans 'Status' %} {% trans 'Datetime' %} {% trans 'Action' %} @@ -39,10 +38,6 @@ function initTable() { $(td).html(detailBtn.replace("{{ DEFAULT_PK }}", rowData.id)); }}, {targets: 3, createdCell: function (td, cellData, rowData) { - var d = cellData + "(" + rowData.city + ")"; - $(td).html(d) - }}, - {targets: 4, createdCell: function (td, cellData, rowData) { if (cellData === "approval") { $(td).html('') } else if (cellData === "rejected") { @@ -53,12 +48,12 @@ function initTable() { $(td).html('') } }}, - {targets: 5, createdCell: function (td, cellData) { + {targets: 4, createdCell: function (td, cellData) { var d = toSafeLocalDateStr(cellData); $(td).html(d) }}, - {targets: 6, createdCell: function (td, cellData, rowData) { - var acceptBtn = '{% trans "Accept" %} '; + {targets: 5, createdCell: function (td, cellData, rowData) { + var acceptBtn = '{% trans "Approve" %} '; var rejectBtn = '{% trans "Reject" %}'; acceptBtn = acceptBtn.replace('{{ DEFAULT_PK }}', cellData); rejectBtn = rejectBtn.replace('{{ DEFAULT_PK }}', cellData); @@ -74,7 +69,7 @@ function initTable() { ajax_url: '{% url "api-tickets:login-confirm-ticket-list" %}', columns: [ {data: "id"}, {data: "title"}, - {data: "user_display"}, {data: "ip"}, + {data: "user_display"}, {data: "status", ticketable: false}, {data: "date_created", width: "120px"}, {data: "id", ticketable: false} @@ -101,18 +96,15 @@ $(document).ready(function(){ ]; initTableFilterDropdown('#login_confirm_ticket_list_table_filter input', menu) }).on('click', '.btn-action', function () { - var actionCreateUrl = "{% url 'api-tickets:login-confirm-ticket-create-action' pk=DEFAULT_PK %}"; - var ticketId = $(this).data('uid'); - actionCreateUrl = actionCreateUrl.replace("{{ DEFAULT_PK }}", ticketId); + var ticketId = $(this).data("uid"); var action = $(this).data('action'); - var comment = ''; + var ticketDetailUrl = "{% url 'api-tickets:login-confirm-ticket-detail' pk=DEFAULT_PK %}"; + ticketDetailUrl = ticketDetailUrl.replace("{{ DEFAULT_PK }}", ticketId); var data = { - url: actionCreateUrl, - method: 'POST', - body: JSON.stringify({action: action, comment: comment}), - success: function () { - window.location.reload(); - } + url: ticketDetailUrl, + body: JSON.stringify({action: action}), + method: "PATCH", + success: reloadPage }; requestApi(data); }) diff --git a/apps/tickets/templates/tickets/ticket_detail.html b/apps/tickets/templates/tickets/ticket_detail.html new file mode 100644 index 000000000..320010398 --- /dev/null +++ b/apps/tickets/templates/tickets/ticket_detail.html @@ -0,0 +1,162 @@ +{% extends 'base.html' %} +{% load static %} +{% load i18n %} + +{% block content %} +
    +
    +
    +
    +
    +
    + {{ object.title }} +
    + +
    +
    +
    +
    +
    +
    +
    +
    {% trans 'User' %}:
    {{ object.user_display }}
    +
    {% trans 'Type' %}:
    {{ object.get_type_display | default_if_none:"" }}
    +
    {% trans 'Status' %}:
    +
    + {% if object.status == "open" %} + + {{ object.get_status_display }} + + {% elif object.status == "closed" %} + + {{ object.get_status_display }} + + {% endif %} +
    +
    +
    +
    +
    +
    {% trans 'Assignees' %}:
    {{ object.assignees_display }}
    +
    {% trans 'Assignee' %}:
    {{ object.assignee_display | default_if_none:"" }}
    +
    {% trans 'Date created' %}:
    {{ object.date_created }}
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + image + +
    + {{ object.user_display }} {{ object.date_created|timesince}} {% trans 'ago' %} +
    + {{ object.date_created }} +
    + {{ object.body_as_html | safe }} +
    +
    +
    + {% for comment in object.comments.all %} + +
    + + image + +
    + {{ comment.user_display }} {{ comment.date_created|timesince}} {% trans 'ago' %} +
    + {{ comment.date_created }} +
    + {{ comment.body }} +
    +
    +
    + {% endfor %} +
    +
    + + image + +
    + +
    +
    +
    + {% block action %} + {% endblock %} + {% block status %} + {% trans 'Close' %} + {% endblock %} + {% block comment %} + {% trans 'Comment' %} + {% endblock %} +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +{% endblock %} +{% block custom_foot_js %} + +{% endblock %} diff --git a/apps/tickets/urls/api_urls.py b/apps/tickets/urls/api_urls.py index c998efda6..1972fb539 100644 --- a/apps/tickets/urls/api_urls.py +++ b/apps/tickets/urls/api_urls.py @@ -9,15 +9,11 @@ app_name = 'tickets' router = DefaultRouter() router.register('tickets', api.TicketViewSet, 'ticket') +router.register('tickets/(?P[0-9a-zA-Z\-]{36})/comments', api.TicketCommentViewSet, 'ticket-comment') router.register('login-confirm-tickets', api.LoginConfirmTicketViewSet, 'login-confirm-ticket') -router.register('tickets//comments/', api.CommentViewSet, 'ticket-comment') urlpatterns = [ - path('login-confirm-tickets//actions/', - api.LoginConfirmTicketsCreateActionApi.as_view(), - name='login-confirm-ticket-create-action' - ), ] urlpatterns += router.urls diff --git a/apps/tickets/views.py b/apps/tickets/views.py index 3816f8097..aac6b136c 100644 --- a/apps/tickets/views.py +++ b/apps/tickets/views.py @@ -1,13 +1,14 @@ from django.views.generic import TemplateView, DetailView from django.utils.translation import ugettext as _ -from common.permissions import PermissionsMixin, IsOrgAdmin +from common.permissions import PermissionsMixin, IsValidUser from .models import LoginConfirmTicket +from . import mixins class LoginConfirmTicketListView(PermissionsMixin, TemplateView): template_name = 'tickets/login_confirm_ticket_list.html' - permission_classes = (IsOrgAdmin,) + permission_classes = (IsValidUser,) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -18,12 +19,10 @@ class LoginConfirmTicketListView(PermissionsMixin, TemplateView): return context -class LoginConfirmTicketDetailView(PermissionsMixin, DetailView): +class LoginConfirmTicketDetailView(PermissionsMixin, mixins.TicketMixin, DetailView): template_name = 'tickets/login_confirm_ticket_detail.html' - permission_classes = (IsOrgAdmin,) - - def get_queryset(self): - return LoginConfirmTicket.objects.filter(assignees=self.request.user) + queryset = LoginConfirmTicket.objects.all() + permission_classes = (IsValidUser,) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/apps/users/serializers/user.py b/apps/users/serializers/user.py index 7e25112d6..b7ec186b7 100644 --- a/apps/users/serializers/user.py +++ b/apps/users/serializers/user.py @@ -13,11 +13,11 @@ from ..models import User, UserGroup __all__ = [ 'UserSerializer', 'UserPKUpdateSerializer', 'UserUpdateGroupSerializer', 'ChangeUserPasswordSerializer', 'ResetOTPSerializer', + 'UserProfileSerializer', ] class UserSerializer(BulkSerializerMixin, serializers.ModelSerializer): - can_update = serializers.SerializerMethodField() can_delete = serializers.SerializerMethodField() @@ -135,3 +135,11 @@ class ResetOTPSerializer(serializers.Serializer): def update(self, instance, validated_data): pass + + +class UserProfileSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = [ + 'id', 'username', 'name', 'role', 'email' + ] From bd323d608e41603a86ad5fc09adefce71e01805a Mon Sep 17 00:00:00 2001 From: ibuler Date: Fri, 8 Nov 2019 16:32:00 +0800 Subject: [PATCH 23/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/api/login_confirm.py | 4 +-- apps/authentication/api/mfa.py | 2 ++ apps/authentication/api/token.py | 2 ++ apps/authentication/errors.py | 33 ++++++++++++------- .../authentication/login_wait_confirm.html | 15 +++++---- 5 files changed, 35 insertions(+), 21 deletions(-) diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py index 57c6dff3f..aa0e43ead 100644 --- a/apps/authentication/api/login_confirm.py +++ b/apps/authentication/api/login_confirm.py @@ -63,8 +63,8 @@ class LoginConfirmTicketStatusApi(APIView): raise errors.LoginConfirmOtherError( ticket_id, ticket.get_status_display() ) - except errors.AuthFailedError as e: - return Response(e.as_data(), status=400) + except errors.NeedMoreInfoError as e: + return Response(e.as_data(), status=200) def delete(self, request, *args, **kwargs): ticket = self.get_ticket() diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index a0bec8216..d55a238d2 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -35,6 +35,8 @@ class MFAChallengeApi(AuthMixin, CreateAPIView): except errors.AuthFailedError as e: data = {"error": e.error, "msg": e.msg} raise ValidationError(data) + except errors.NeedMoreInfoError as e: + return Response(e.as_data(), status=200) def create(self, request, *args, **kwargs): super().create(request, *args, **kwargs) diff --git a/apps/authentication/api/token.py b/apps/authentication/api/token.py index 7242cf9b5..980ff4c11 100644 --- a/apps/authentication/api/token.py +++ b/apps/authentication/api/token.py @@ -37,3 +37,5 @@ class TokenCreateApi(AuthMixin, CreateAPIView): return resp except errors.AuthFailedError as e: return Response(e.as_data(), status=400) + except errors.NeedMoreInfoError as e: + return Response(e.as_data(), status=200) diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 5e7506e90..18fb09e73 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -130,7 +130,24 @@ class SessionEmptyError(AuthFailedError): error = 'session_empty' -class MFARequiredError(AuthFailedError): +class NeedMoreInfoError(Exception): + error = '' + msg = '' + + def __init__(self, error='', msg=''): + if error: + self.error = error + if msg: + self.msg = '' + + def as_data(self): + return { + 'error': self.error, + 'msg': self.msg, + } + + +class MFARequiredError(NeedMoreInfoError): msg = mfa_required_msg error = 'mfa_required' @@ -145,15 +162,7 @@ class MFARequiredError(AuthFailedError): } -class LoginConfirmRequiredError(AuthFailedError): - msg = login_confirm_required_msg - error = 'login_confirm_required' - - -class LoginConfirmError(AuthFailedError): - msg = login_confirm_wait_msg - error = 'login_confirm_wait' - +class LoginConfirmBaseError(NeedMoreInfoError): def __init__(self, ticket_id, **kwargs): self.ticket_id = ticket_id super().__init__(**kwargs) @@ -168,12 +177,12 @@ class LoginConfirmError(AuthFailedError): } -class LoginConfirmWaitError(LoginConfirmError): +class LoginConfirmWaitError(LoginConfirmBaseError): msg = login_confirm_wait_msg error = 'login_confirm_wait' -class LoginConfirmOtherError(LoginConfirmError): +class LoginConfirmOtherError(LoginConfirmBaseError): error = 'login_confirm_error' def __init__(self, ticket_id, status): diff --git a/apps/authentication/templates/authentication/login_wait_confirm.html b/apps/authentication/templates/authentication/login_wait_confirm.html index e653fb072..3be63bd1f 100644 --- a/apps/authentication/templates/authentication/login_wait_confirm.html +++ b/apps/authentication/templates/authentication/login_wait_confirm.html @@ -80,12 +80,7 @@ function doRequestAuth() { requestApi({ url: url, method: "GET", - success: function () { - clearInterval(interval); - clearInterval(checkInterval); - window.location = successUrl; - }, - error: function (text, data) { + success: function (data) { if (data.error !== "login_confirm_wait") { if (!errorMsgShow) { infoMsgRef.hide(); @@ -97,7 +92,13 @@ function doRequestAuth() { clearInterval(checkInterval); $(".copy-btn").attr('disabled', 'disabled') } - errorMsgRef.html(data.msg) + if (data.msg === 'ok' && !data.error) { + window.location = "{% url 'authentication:login-guard' %}" + } else { + errorMsgRef.html(data.msg) + } + }, + error: function (text, data) { }, flash_message: false }) From 9e4874834f9f9166c612d04915afba916ad8c2a6 Mon Sep 17 00:00:00 2001 From: ibuler Date: Fri, 8 Nov 2019 20:17:25 +0800 Subject: [PATCH 24/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9tickets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/api/login_confirm.py | 23 ++----- apps/authentication/api/token.py | 2 +- apps/authentication/mixins.py | 65 ++++++++++++++---- .../authentication/login_wait_confirm.html | 6 +- apps/authentication/views/login.py | 52 +++++--------- apps/locale/zh/LC_MESSAGES/django.mo | Bin 84326 -> 84406 bytes apps/locale/zh/LC_MESSAGES/django.po | 57 ++++++++------- apps/static/js/jumpserver.js | 2 +- apps/tickets/api/login_confirm.py | 18 +---- apps/tickets/serializers/login_confirm.py | 10 ++- .../tickets/login_confirm_ticket_list.html | 49 +++++++++++-- apps/tickets/urls/api_urls.py | 5 +- 12 files changed, 169 insertions(+), 120 deletions(-) diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py index aa0e43ead..e64deeef8 100644 --- a/apps/authentication/api/login_confirm.py +++ b/apps/authentication/api/login_confirm.py @@ -10,7 +10,7 @@ from common.utils import get_logger, get_object_or_none from common.permissions import IsOrgAdmin from ..models import LoginConfirmSetting from ..serializers import LoginConfirmSettingSerializer -from .. import errors +from .. import errors, mixins __all__ = ['LoginConfirmSettingUpdateApi', 'LoginConfirmTicketStatusApi'] logger = get_logger(__name__) @@ -31,7 +31,7 @@ class LoginConfirmSettingUpdateApi(UpdateAPIView): return s -class LoginConfirmTicketStatusApi(APIView): +class LoginConfirmTicketStatusApi(mixins.AuthMixin, APIView): permission_classes = () def get_ticket(self): @@ -45,24 +45,9 @@ class LoginConfirmTicketStatusApi(APIView): return ticket def get(self, request, *args, **kwargs): - ticket_id = self.request.session.get("auth_ticket_id") - ticket = self.get_ticket() try: - if not ticket: - raise errors.LoginConfirmOtherError(ticket_id, _("not found")) - if ticket.status == 'open': - raise errors.LoginConfirmWaitError(ticket_id) - elif ticket.action == ticket.ACTION_APPROVE: - self.request.session["auth_confirm"] = "1" - return Response({"msg": "ok"}) - elif ticket.action == ticket.ACTION_REJECT: - raise errors.LoginConfirmOtherError( - ticket_id, ticket.get_action_display() - ) - else: - raise errors.LoginConfirmOtherError( - ticket_id, ticket.get_status_display() - ) + self.check_user_login_confirm() + return Response({"msg": "ok"}) except errors.NeedMoreInfoError as e: return Response(e.as_data(), status=200) diff --git a/apps/authentication/api/token.py b/apps/authentication/api/token.py index 980ff4c11..bea70e4b7 100644 --- a/apps/authentication/api/token.py +++ b/apps/authentication/api/token.py @@ -28,7 +28,7 @@ class TokenCreateApi(AuthMixin, CreateAPIView): self.create_session_if_need() # 如果认证没有过,检查账号密码 try: - user = self.check_user_auth() + user = self.check_user_auth_if_need() self.check_user_mfa_if_need(user) self.check_user_login_confirm_if_need(user) self.send_auth_signal(success=True, user=user) diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 3d6ca0321..9be582c16 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # import time +from django.conf import settings from common.utils import get_object_or_none, get_request_ip, get_logger from users.models import User @@ -49,8 +50,8 @@ class AuthMixin: raise errors.BlockLoginError(username=username, ip=ip) def check_user_auth(self): - request = self.request self.check_is_block() + request = self.request if hasattr(request, 'data'): username = request.data.get('username', '') password = request.data.get('password', '') @@ -73,11 +74,20 @@ class AuthMixin: request.session['user_id'] = str(user.id) return user + def check_user_auth_if_need(self): + request = self.request + if request.session.get('auth_password') and \ + request.session.get('user_id'): + user = self.get_user_from_session() + if user: + return user + return self.check_user_auth() + def check_user_mfa_if_need(self, user): if self.request.session.get('auth_mfa'): - return True + return if not user.otp_enabled or not user.otp_secret_key: - return True + return raise errors.MFARequiredError() def check_user_mfa(self, code): @@ -90,28 +100,53 @@ class AuthMixin: return raise errors.MFAFailedError(username=user.username, request=self.request) - def check_user_login_confirm_if_need(self, user): + def get_ticket(self): from tickets.models import LoginConfirmTicket - confirm_setting = user.get_login_confirm_setting() - if self.request.session.get('auth_confirm') or not confirm_setting: - return - ticket = None - if self.request.session.get('auth_ticket_id'): - ticket_id = self.request.session['auth_ticket_id'] + ticket_id = self.request.session.get("auth_ticket_id") + logger.debug('Login confirm ticket id: {}'.format(ticket_id)) + if not ticket_id: + ticket = None + else: ticket = get_object_or_none(LoginConfirmTicket, pk=ticket_id) + return ticket + + def get_ticket_or_create(self, confirm_setting): + ticket = self.get_ticket() if not ticket: ticket = confirm_setting.create_confirm_ticket(self.request) self.request.session['auth_ticket_id'] = str(ticket.id) + return ticket - if ticket.status == "accepted": - return - elif ticket.status == "rejected": - raise errors.LoginConfirmOtherError(ticket.id) - else: + def check_user_login_confirm(self): + ticket = self.get_ticket() + if not ticket: + raise errors.LoginConfirmOtherError('', "Not found") + if ticket.status == ticket.STATUS_OPEN: raise errors.LoginConfirmWaitError(ticket.id) + elif ticket.action == ticket.ACTION_APPROVE: + self.request.session["auth_confirm"] = "1" + return + elif ticket.action == ticket.ACTION_REJECT: + raise errors.LoginConfirmOtherError( + ticket.id, ticket.get_action_display() + ) + else: + raise errors.LoginConfirmOtherError( + ticket.id, ticket.get_status_display() + ) + + def check_user_login_confirm_if_need(self, user): + if not settings.CONFIG.LOGIN_CONFIRM_ENABLE: + return + confirm_setting = user.get_login_confirm_setting() + if self.request.session.get('auth_confirm') or not confirm_setting: + return + self.get_ticket_or_create(confirm_setting) + self.check_user_login_confirm() def clear_auth_mark(self): self.request.session['auth_password'] = '' + self.request.session['auth_user_id'] = '' self.request.session['auth_mfa'] = '' self.request.session['auth_confirm'] = '' self.request.session['auth_ticket_id'] = '' diff --git a/apps/authentication/templates/authentication/login_wait_confirm.html b/apps/authentication/templates/authentication/login_wait_confirm.html index 3be63bd1f..845179db4 100644 --- a/apps/authentication/templates/authentication/login_wait_confirm.html +++ b/apps/authentication/templates/authentication/login_wait_confirm.html @@ -43,7 +43,7 @@
    @@ -132,7 +132,11 @@ $(document).ready(function () { checkInterval = setInterval(doRequestAuth, 5000); doRequestAuth(); initClipboard(); + window.onbeforeunload = function (e) { + return "{% trans "Confirm" %}"; + }; }).on('click', '.btn-refresh', function () { + window.onbeforeunload = function() {}; window.location.reload(); }) diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 2bfa3d894..6dd3355c0 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -19,9 +19,7 @@ from django.conf import settings from django.urls import reverse_lazy from common.utils import get_request_ip, get_object_or_none -from users.models import User from users.utils import ( - get_user_or_tmp_user, increase_login_failed_count, redirect_user_first_login_or_index ) from ..signals import post_auth_success, post_auth_failed @@ -117,42 +115,28 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView): return url def get_redirect_url(self, *args, **kwargs): - if not self.request.session.get('auth_password'): + try: + user = self.check_user_auth_if_need() + self.check_user_mfa_if_need(user) + self.check_user_login_confirm_if_need(user) + except errors.CredentialError: return self.format_redirect_url(self.login_url) - user = self.get_user_from_session() - # 启用并设置了otp - if user.otp_enabled and user.otp_secret_key and \ - not self.request.session.get('auth_mfa'): + except errors.MFARequiredError: return self.format_redirect_url(self.login_otp_url) - confirm_setting = user.get_login_confirm_setting() - if confirm_setting and not self.request.session.get('auth_confirm'): - ticket = confirm_setting.create_confirm_ticket(self.request) - self.request.session['auth_ticket_id'] = str(ticket.id) - url = self.format_redirect_url(self.login_confirm_url) - return url - self.login_success(user) - self.clear_auth_mark() - # 启用但是没有设置otp - if user.otp_enabled and not user.otp_secret_key: - # 1,2,mfa_setting & F - return reverse('users:user-otp-enable-authentication') - url = redirect_user_first_login_or_index( - self.request, self.redirect_field_name - ) - return url - - def login_success(self, user): - auth_login(self.request, user) - self.send_auth_signal(success=True, user=user) - - def send_auth_signal(self, success=True, user=None, username='', reason=''): - if success: - post_auth_success.send(sender=self.__class__, user=user, request=self.request) + except errors.LoginConfirmBaseError: + return self.format_redirect_url(self.login_confirm_url) else: - post_auth_failed.send( - sender=self.__class__, username=username, - request=self.request, reason=reason + auth_login(self.request, user) + self.send_auth_signal(success=True, user=user) + self.clear_auth_mark() + # 启用但是没有设置otp + if user.otp_enabled and not user.otp_secret_key: + # 1,2,mfa_setting & F + return reverse('users:user-otp-enable-authentication') + url = redirect_user_first_login_or_index( + self.request, self.redirect_field_name ) + return url class UserLoginWaitConfirmView(TemplateView): diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index b591fda3cebf031a86eaee0c9278171b85c86701..17690d835c822f7ef80ba8e6607b7dc1302d9b15 100644 GIT binary patch delta 24429 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 delta 24364 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%0D7K5r3sD zIYv<4msm~)h(EGY7RZ@}cxLK+PCEWbL2LoBTGT&??mc9s-3X4P!fx_wDO`m=k)K5e ze_$zM$B6xjjj3CS_%_m^#9ESXM|v3bs&QW9j6X_}NJ)7`P93dr2W?Yxj@R{%KMs(H zqLaU9T$jRNP91wNBk}m-7Kz7JR-JTG(#LGTB;g2>D#C7EUl9>)ia*OvKAz)0#OA>Ih5aL(fVk~-Zuf23ST1)F;| za}n|Tw5dYA7U_l5*+PD+zRfHmv!70;QSd41cttuPX9t({|13{~?!$8*-DJfBU#ioL?bbK4kYC?BA2vpT9e{C`7fN2S`97td&r6Q|K&p*0So z&R@j7#`vQs>946%(gs~m-A7j57VDAk7hf5ZQun1k%}R3W;17ZR{}xi|j_=HT2IZSL z^HKf*om{58IA*fWRX_e%O1u$u!GBD(M6mUjm@*x0Ebd?bHZorj457msocB2Y^;hE? z4h@=F<#fbmaXz6u6S0igoAQlVlmW^TyG!|COhD{1WuMuAS*;Is+n%!9Qgz(LS zU=D&hRy(|uREVMR8O}TJG*)pPt0TUkybgo^O?nD_%z8^){=r_P?VIJc&j+cHkmn&q!A#9m(WAC0~d03pyx9ti8Xc`|%U}O?`f{`J2^0MSKwX_~VGKe;9$;6x7H3basQvd&$4zJWqNQX9V#x z7Lbp1G8 zVIdMPtb;NXzV9#kzaRZcmgVe9Sr_WvrNezzypVha@pz1`{E@}dC8%78P6t@WdFc2Q=?#?MvDk4M-L+2D{zGf~E$JZQRXEcV z+t0b&+Nhq6O!|vb13F#(P6uPCn8-RQL4!h+zkAFklZMz?Yg3U>HpBe9*F0o1?!f9q#Zg6laGQ*jj)*Wwl2Kr9z$Mw>`dTuS3NlW;!IRC>vonA3m#zb06TN@r|-}(hz(1$Va^VJAoL=33=PB-gesFCHEMa?jgPt z`&;Ga<_u!DNH?S2Ch8O<{g0Jpr|cu^ZyDtcNI%CY+r72qD^Mql{6O05(O)xG60A+& z9Dxrh)bTBi{^jgw`Lv{WQa*_DAh8tG8G+l$-ywYobxfw7j$)i;C@+8sDLX`brqxNt zSS`tKro56q|1*<#!5M#4rb0b36Da!y%c^xkt{-2ll zd7ARulszI{j&mC45XS4w={wAi>jc`+`L|T8PQ_+6P!H0rDc{98lyeMc4(b%dSy-2P zE2!fmuj3QUVr|E{kpG{_enMRxy(tf;{E5rzXQ21q`u4F|Gq0aI=vl7y6PBb8S-fJxQOYcv5by{ERr>Wl9#lP`Y{35mA6qOi diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index 00b3ac1c4..69f6df717 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: 2019-11-08 15:42+0800\n" +"POT-Creation-Date: 2019-11-08 17:27+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: ibuler \n" "Language-Team: Jumpserver team\n" @@ -354,6 +354,7 @@ msgstr "重置" #: terminal/templates/terminal/command_list.html:47 #: terminal/templates/terminal/session_list.html:52 #: terminal/templates/terminal/terminal_update.html:46 +#: tickets/templates/tickets/login_confirm_ticket_list.html:32 #: users/templates/users/_user.html:52 #: users/templates/users/forgot_password.html:42 #: users/templates/users/user_bulk_update.html:24 @@ -528,7 +529,7 @@ msgstr "创建远程应用" #: terminal/templates/terminal/session_list.html:36 #: terminal/templates/terminal/terminal_list.html:36 #: tickets/templates/tickets/login_confirm_ticket_list.html:18 -#: tickets/templates/tickets/login_confirm_ticket_list.html:92 +#: tickets/templates/tickets/login_confirm_ticket_list.html:105 #: users/templates/users/_granted_assets.html:34 #: users/templates/users/user_group_list.html:38 #: users/templates/users/user_list.html:41 @@ -1480,7 +1481,7 @@ msgid "Asset user auth" msgstr "资产用户信息" #: assets/templates/assets/_asset_user_auth_view_modal.html:54 -#: authentication/templates/authentication/login_wait_confirm.html:114 +#: authentication/templates/authentication/login_wait_confirm.html:115 msgid "Copy success" msgstr "复制成功" @@ -1666,6 +1667,7 @@ msgstr "选择节点" #: assets/templates/assets/system_user_detail.html:182 #: assets/templates/assets/system_user_list.html:135 #: authentication/templates/authentication/_mfa_confirm_modal.html:20 +#: authentication/templates/authentication/login_wait_confirm.html:136 #: settings/templates/settings/terminal_setting.html:168 #: templates/_modal.html:23 terminal/templates/terminal/session_detail.html:112 #: users/templates/users/user_detail.html:271 @@ -2294,7 +2296,7 @@ msgstr "原因" #: audits/models.py:88 audits/templates/audits/login_log_list.html:64 #: tickets/templates/tickets/login_confirm_ticket_list.html:16 -#: tickets/templates/tickets/login_confirm_ticket_list.html:88 +#: tickets/templates/tickets/login_confirm_ticket_list.html:101 #: tickets/templates/tickets/ticket_detail.html:34 #: xpack/plugins/cloud/models.py:275 xpack/plugins/cloud/models.py:310 #: xpack/plugins/cloud/templates/cloud/sync_instance_task_history.html:70 @@ -2390,10 +2392,6 @@ msgstr "登录日志" msgid "Command execution log" msgstr "命令执行" -#: authentication/api/login_confirm.py:52 -msgid "not found" -msgstr "没有发现" - #: authentication/backends/api.py:53 msgid "Invalid signature header. No credentials provided." msgstr "" @@ -2703,11 +2701,11 @@ msgstr "返回" msgid "Welcome back, please enter username and password to login" msgstr "欢迎回来,请输入用户名和密码登录" -#: authentication/views/login.py:73 +#: authentication/views/login.py:71 msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: authentication/views/login.py:172 +#: authentication/views/login.py:170 msgid "" "Wait for {} confirm, You also can copy link to her/him
    \n" " Don't close this page" @@ -2715,15 +2713,15 @@ msgstr "" "等待 {} 确认, 你也可以复制链接发给他/她
    \n" " 不要关闭本页面" -#: authentication/views/login.py:177 +#: authentication/views/login.py:175 msgid "No ticket found" msgstr "没有发现工单" -#: authentication/views/login.py:200 +#: authentication/views/login.py:198 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:201 +#: authentication/views/login.py:199 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" @@ -4571,8 +4569,8 @@ msgstr "接受" #: terminal/templates/terminal/terminal_list.html:80 #: tickets/models/login_confirm.py:16 #: tickets/templates/tickets/login_confirm_ticket_detail.html:10 -#: tickets/templates/tickets/login_confirm_ticket_list.html:57 -#: tickets/templates/tickets/login_confirm_ticket_list.html:94 +#: tickets/templates/tickets/login_confirm_ticket_list.html:70 +#: tickets/templates/tickets/login_confirm_ticket_list.html:107 msgid "Reject" msgstr "拒绝" @@ -4610,12 +4608,12 @@ msgid "" msgstr "你可以使用ssh客户端工具连接终端" #: tickets/models/base.py:16 tickets/models/base.py:52 -#: tickets/templates/tickets/login_confirm_ticket_list.html:89 +#: tickets/templates/tickets/login_confirm_ticket_list.html:102 msgid "Open" -msgstr "" +msgstr "开启" #: tickets/models/base.py:17 -#: tickets/templates/tickets/login_confirm_ticket_list.html:90 +#: tickets/templates/tickets/login_confirm_ticket_list.html:103 msgid "Closed" msgstr "关闭" @@ -4629,7 +4627,7 @@ msgstr "用户显示名称" #: tickets/models/base.py:28 #: tickets/templates/tickets/login_confirm_ticket_list.html:14 -#: tickets/templates/tickets/login_confirm_ticket_list.html:87 +#: tickets/templates/tickets/login_confirm_ticket_list.html:100 msgid "Title" msgstr "标题" @@ -4659,8 +4657,8 @@ msgstr "{} {} 这个工单" #: tickets/models/login_confirm.py:15 #: tickets/templates/tickets/login_confirm_ticket_detail.html:9 -#: tickets/templates/tickets/login_confirm_ticket_list.html:56 -#: tickets/templates/tickets/login_confirm_ticket_list.html:93 +#: tickets/templates/tickets/login_confirm_ticket_list.html:69 +#: tickets/templates/tickets/login_confirm_ticket_list.html:106 msgid "Approve" msgstr "同意" @@ -4668,6 +4666,14 @@ msgstr "同意" msgid "this order" msgstr "这个工单" +#: tickets/templates/tickets/login_confirm_ticket_list.html:27 +msgid "Approve selected" +msgstr "同意所选" + +#: tickets/templates/tickets/login_confirm_ticket_list.html:28 +msgid "Reject selected" +msgstr "拒绝所选" + #: tickets/templates/tickets/ticket_detail.html:66 #: tickets/templates/tickets/ticket_detail.html:81 msgid "ago" @@ -6431,6 +6437,12 @@ msgstr "密码匣子" msgid "vault create" msgstr "创建" +#~ msgid "selected" +#~ msgstr "所选" + +#~ msgid "not found" +#~ msgstr "没有发现" + #~ msgid "Log in frequently and try again later" #~ msgstr "登录频繁, 稍后重试" @@ -6446,9 +6458,6 @@ msgstr "创建" #~ msgid "Accepted" #~ msgstr "已接受" -#~ msgid "Rejected" -#~ msgstr "已拒绝" - #~ msgid "New order" #~ msgstr "新工单" diff --git a/apps/static/js/jumpserver.js b/apps/static/js/jumpserver.js index fdbbfd1f9..b16d3cdfc 100644 --- a/apps/static/js/jumpserver.js +++ b/apps/static/js/jumpserver.js @@ -1319,5 +1319,5 @@ function initDateRangePicker(selector, options) { } function reloadPage() { - window.location.reload(); + setTimeout( function () {window.location.reload();}, 300); } diff --git a/apps/tickets/api/login_confirm.py b/apps/tickets/api/login_confirm.py index 599931a52..e0c6f9cf5 100644 --- a/apps/tickets/api/login_confirm.py +++ b/apps/tickets/api/login_confirm.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- # -from rest_framework import viewsets, generics -from rest_framework.serializers import ValidationError -from django.shortcuts import get_object_or_404 +from rest_framework_bulk import BulkModelViewSet from common.permissions import IsValidUser from common.mixins import CommonApiMixin @@ -10,21 +8,9 @@ from .. import serializers, mixins from ..models import LoginConfirmTicket -class LoginConfirmTicketViewSet(CommonApiMixin, mixins.TicketMixin, viewsets.ModelViewSet): +class LoginConfirmTicketViewSet(CommonApiMixin, mixins.TicketMixin, BulkModelViewSet): serializer_class = serializers.LoginConfirmTicketSerializer permission_classes = (IsValidUser,) queryset = LoginConfirmTicket.objects.all() filter_fields = ['status', 'title', 'action', 'ip'] search_fields = ['user_display', 'title', 'ip', 'city'] - - # def check_update_permission(self, serializer): - # data = serializer.validated_data - # action = data.get("action") - # user = self.request.user - # instance = serializer.instance - # if action and user not in instance.assignees.all(): - # error = {"action": "Only assignees can update"} - # raise ValidationError(error) - # - # def perform_update(self, serializer): - # self.check_update_permission(serializer) diff --git a/apps/tickets/serializers/login_confirm.py b/apps/tickets/serializers/login_confirm.py index e7e224c1e..cdd450d6d 100644 --- a/apps/tickets/serializers/login_confirm.py +++ b/apps/tickets/serializers/login_confirm.py @@ -2,6 +2,8 @@ # from rest_framework import serializers +from common.serializers import AdaptedBulkListSerializer +from common.mixins.serializers import BulkSerializerMixin from .base import TicketSerializer from ..models import LoginConfirmTicket @@ -9,8 +11,9 @@ from ..models import LoginConfirmTicket __all__ = ['LoginConfirmTicketSerializer', 'LoginConfirmTicketActionSerializer'] -class LoginConfirmTicketSerializer(serializers.ModelSerializer): +class LoginConfirmTicketSerializer(BulkSerializerMixin, serializers.ModelSerializer): class Meta: + list_serializer_class = AdaptedBulkListSerializer model = LoginConfirmTicket fields = TicketSerializer.Meta.fields + [ 'ip', 'city', 'action' @@ -24,11 +27,14 @@ class LoginConfirmTicketSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): action = validated_data.get("action") user = self.context["request"].user + if action and user not in instance.assignees.all(): error = {"action": "Only assignees can update"} raise serializers.ValidationError(error) + if instance.status == instance.STATUS_CLOSED: + validated_data.pop('action') instance = super().update(instance, validated_data) - if action: + if not instance.status == instance.STATUS_CLOSED: instance.perform_action(action, user) return instance diff --git a/apps/tickets/templates/tickets/login_confirm_ticket_list.html b/apps/tickets/templates/tickets/login_confirm_ticket_list.html index 51a14b3ae..267af970b 100644 --- a/apps/tickets/templates/tickets/login_confirm_ticket_list.html +++ b/apps/tickets/templates/tickets/login_confirm_ticket_list.html @@ -21,6 +21,19 @@ +
    +
    + +
    + +
    +
    +
    {% include '_filter_dropdown.html' %} {% endblock %} {% block content_bottom_left %}{% endblock %} @@ -38,9 +51,9 @@ function initTable() { $(td).html(detailBtn.replace("{{ DEFAULT_PK }}", rowData.id)); }}, {targets: 3, createdCell: function (td, cellData, rowData) { - if (cellData === "approval") { + if (cellData === "approve") { $(td).html('') - } else if (cellData === "rejected") { + } else if (cellData === "reject") { $(td).html('') } else if (cellData === "open") { $(td).html('') @@ -70,9 +83,9 @@ function initTable() { columns: [ {data: "id"}, {data: "title"}, {data: "user_display"}, - {data: "status", ticketable: false}, + {data: "action", width: "40px"}, {data: "date_created", width: "120px"}, - {data: "id", ticketable: false} + {data: "id", orderable: false} ], op_html: $('#actions').html() }; @@ -85,6 +98,7 @@ $(document).ready(function(){ var menu = [ {title: "IP", value: "ip"}, {title: "{% trans 'Title' %}", value: "title"}, + {title: "{% trans 'User' %}", value: "user_display"}, {title: "{% trans 'Status' %}", value: "status", submenu: [ {title: "{% trans 'Open' %}", value: "open"}, {title: "{% trans 'Closed' %}", value: "closed"}, @@ -107,6 +121,33 @@ $(document).ready(function(){ success: reloadPage }; requestApi(data); +}).on('click', '#btn_bulk_update', function () { + var action = $('#slct_bulk_update').val(); + var idList = ticketTable.selected; + if (idList.length === 0) { + return false; + } + var theUrl = "{% url 'api-tickets:login-confirm-ticket-list' %}"; + + function doAction(action) { + var data = []; + $.each(idList, function(index, object_id) { + var obj = { + "pk": object_id, "action": action + }; + data.push(obj); + }); + requestApi({ + url: theUrl, + method: 'PATCH', + body: JSON.stringify(data), + success: function (){ + $(".ipt_check_all").prop("checked", false) + ticketTable.ajax.reload(); + } + }); + } + doAction(action) }) {% endblock %} diff --git a/apps/tickets/urls/api_urls.py b/apps/tickets/urls/api_urls.py index 1972fb539..d160235ab 100644 --- a/apps/tickets/urls/api_urls.py +++ b/apps/tickets/urls/api_urls.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- # -from django.urls import path -from rest_framework.routers import DefaultRouter +from rest_framework_bulk.routes import BulkRouter from .. import api app_name = 'tickets' -router = DefaultRouter() +router = BulkRouter() router.register('tickets', api.TicketViewSet, 'ticket') router.register('tickets/(?P[0-9a-zA-Z\-]{36})/comments', api.TicketCommentViewSet, 'ticket-comment') From 74f22274b4a3be7464259d7a1683aee2caa3c2e0 Mon Sep 17 00:00:00 2001 From: ibuler Date: Mon, 11 Nov 2019 10:58:11 +0800 Subject: [PATCH 25/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/authentication/login_wait_confirm.html | 10 ++++------ .../templates/authentication/xpack_login.html | 2 ++ apps/authentication/views/login.py | 5 +++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/authentication/templates/authentication/login_wait_confirm.html b/apps/authentication/templates/authentication/login_wait_confirm.html index 845179db4..99841438e 100644 --- a/apps/authentication/templates/authentication/login_wait_confirm.html +++ b/apps/authentication/templates/authentication/login_wait_confirm.html @@ -81,7 +81,9 @@ function doRequestAuth() { url: url, method: "GET", success: function (data) { - if (data.error !== "login_confirm_wait") { + if (!data.error && data.msg === 'ok') { + window.location = "{% url 'authentication:login-guard' %}" + } else if (data.error !== "login_confirm_wait") { if (!errorMsgShow) { infoMsgRef.hide(); errorMsgRef.show(); @@ -90,11 +92,7 @@ function doRequestAuth() { } clearInterval(interval); clearInterval(checkInterval); - $(".copy-btn").attr('disabled', 'disabled') - } - if (data.msg === 'ok' && !data.error) { - window.location = "{% url 'authentication:login-guard' %}" - } else { + $(".copy-btn").attr('disabled', 'disabled'); errorMsgRef.html(data.msg) } }, diff --git a/apps/authentication/templates/authentication/xpack_login.html b/apps/authentication/templates/authentication/xpack_login.html index 2035cc360..ba3f92308 100644 --- a/apps/authentication/templates/authentication/xpack_login.html +++ b/apps/authentication/templates/authentication/xpack_login.html @@ -85,6 +85,8 @@
    {% elif form.errors.captcha %}

    {% trans 'Captcha invalid' %}

    + {% else %} +
    {% endif %}
    diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 6dd3355c0..94226a237 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -22,7 +22,6 @@ from common.utils import get_request_ip, get_object_or_none from users.utils import ( redirect_user_first_login_or_index ) -from ..signals import post_auth_success, post_auth_failed from .. import forms, mixins, errors @@ -75,7 +74,9 @@ class UserLoginView(mixins.AuthMixin, FormView): form.add_error(None, e.msg) ip = self.get_request_ip() cache.set(self.key_prefix_captcha.format(ip), 1, 3600) - context = self.get_context_data(form=form) + new_form = self.form_class_captcha(data=form.data) + new_form._errors = form.errors + context = self.get_context_data(form=new_form) return self.render_to_response(context) return self.redirect_to_guard_view() From 0b211d33b2e9961f67e83304c45ccbe3c058bf2d Mon Sep 17 00:00:00 2001 From: ibuler Date: Mon, 11 Nov 2019 11:54:32 +0800 Subject: [PATCH 26/55] =?UTF-8?q?[Bugfix]=20=E4=BF=AE=E5=A4=8D=E6=B2=A1?= =?UTF-8?q?=E6=9C=89backend=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/mixins.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 9be582c16..be7bfaf7d 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -30,6 +30,7 @@ class AuthMixin: user = get_object_or_none(User, pk=user_id) if not user: raise errors.SessionEmptyError() + user.backend = self.request.session.get("auth_backend") return user def get_request_ip(self): @@ -72,6 +73,8 @@ class AuthMixin: clean_failed_count(username, ip) request.session['auth_password'] = 1 request.session['user_id'] = str(user.id) + auth_backend = getattr(user, 'backend', 'django.contrib.auth.backends.ModelBackend') + request.session['auth_backend'] = auth_backend return user def check_user_auth_if_need(self): From aeff0ab5f338a7d5c842f4657ef584cb97dc47b0 Mon Sep 17 00:00:00 2001 From: ibuler Date: Mon, 11 Nov 2019 12:43:00 +0800 Subject: [PATCH 27/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9public=20key?= =?UTF-8?q?=20=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/backends/pubkey.py | 39 ++++++++++++++++++++++++++ apps/authentication/utils.py | 14 ++------- apps/jumpserver/settings.py | 1 + apps/users/models/user.py | 17 +++++++++++ 4 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 apps/authentication/backends/pubkey.py diff --git a/apps/authentication/backends/pubkey.py b/apps/authentication/backends/pubkey.py new file mode 100644 index 000000000..db0ace648 --- /dev/null +++ b/apps/authentication/backends/pubkey.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# +from django.contrib.auth import get_user_model + +UserModel = get_user_model() + +__all__ = ['PublicKeyAuthBackend'] + + +class PublicKeyAuthBackend: + def authenticate(self, request, username=None, public_key=None, **kwargs): + if not public_key: + return None + if username is None: + username = kwargs.get(UserModel.USERNAME_FIELD) + try: + user = UserModel._default_manager.get_by_natural_key(username) + except UserModel.DoesNotExist: + return None + else: + if user.check_public_key(public_key) and \ + self.user_can_authenticate(user): + return user + + @staticmethod + def user_can_authenticate(user): + """ + Reject users with is_active=False. Custom user models that don't have + that attribute are allowed. + """ + is_active = getattr(user, 'is_active', None) + return is_active or is_active is None + + def get_user(self, user_id): + try: + user = UserModel._default_manager.get(pk=user_id) + except UserModel.DoesNotExist: + return None + return user if self.user_can_authenticate(user) else None diff --git a/apps/authentication/utils.py b/apps/authentication/utils.py index 1f190bef5..eb1649885 100644 --- a/apps/authentication/utils.py +++ b/apps/authentication/utils.py @@ -33,17 +33,9 @@ def check_user_valid(**kwargs): elif user.password_has_expired: return None, errors.reason_password_expired - if password: - user = authenticate(request, username=username, password=password) + if password or public_key: + user = authenticate(request, username=username, + password=password, public_key=public_key) if user: return user, '' - - if public_key and user.public_key: - public_key_saved = user.public_key.split() - if len(public_key_saved) == 1: - public_key_saved = public_key_saved[0] - else: - public_key_saved = public_key_saved[1] - if public_key == public_key_saved: - return user, '' return None, errors.reason_password_failed diff --git a/apps/jumpserver/settings.py b/apps/jumpserver/settings.py index 3eea3c78a..db2785d58 100644 --- a/apps/jumpserver/settings.py +++ b/apps/jumpserver/settings.py @@ -411,6 +411,7 @@ REST_FRAMEWORK = { AUTHENTICATION_BACKENDS = [ 'django.contrib.auth.backends.ModelBackend', + 'authentication.backends.pubkey.PublicKeyAuthBackend', ] # Custom User Auth model diff --git a/apps/users/models/user.py b/apps/users/models/user.py index c987fbd91..bf8186174 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -120,6 +120,23 @@ class AuthMixin: return s return False + @staticmethod + def get_public_key_body(key): + for i in key.split(): + if len(i) > 256: + return i + return key + + def check_public_key(self, key): + if not self.public_key: + return False + key = self.get_public_key_body(key) + key_saved = self.get_public_key_body(self.public_key) + if key == key_saved: + return True + else: + return False + class RoleMixin: ROLE_ADMIN = 'Admin' From f53cf8d544b391550937821680636c3972afd436 Mon Sep 17 00:00:00 2001 From: ibuler Date: Mon, 11 Nov 2019 16:43:09 +0800 Subject: [PATCH 28/55] =?UTF-8?q?[Bugfix]=20=E4=BF=AE=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=99=BB=E9=99=86=E5=B7=A5=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/api/login_confirm.py | 1 + apps/authentication/errors.py | 2 +- apps/authentication/mixins.py | 2 +- .../authentication/login_wait_confirm.html | 40 +++++++++++++++---- apps/authentication/views/login.py | 2 +- 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py index e64deeef8..5232dd753 100644 --- a/apps/authentication/api/login_confirm.py +++ b/apps/authentication/api/login_confirm.py @@ -54,5 +54,6 @@ class LoginConfirmTicketStatusApi(mixins.AuthMixin, APIView): def delete(self, request, *args, **kwargs): ticket = self.get_ticket() if ticket: + request.session.pop('auth_ticket_id', '') ticket.perform_status('closed', request.user) return Response('', status=200) diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 18fb09e73..9df4ad1f7 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -138,7 +138,7 @@ class NeedMoreInfoError(Exception): if error: self.error = error if msg: - self.msg = '' + self.msg = msg def as_data(self): return { diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index be7bfaf7d..ceb54f592 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -115,7 +115,7 @@ class AuthMixin: def get_ticket_or_create(self, confirm_setting): ticket = self.get_ticket() - if not ticket: + if not ticket or ticket.status == ticket.STATUS_CLOSED: ticket = confirm_setting.create_confirm_ticket(self.request) self.request.session['auth_ticket_id'] = str(ticket.id) return ticket diff --git a/apps/authentication/templates/authentication/login_wait_confirm.html b/apps/authentication/templates/authentication/login_wait_confirm.html index 99841438e..28f84baf9 100644 --- a/apps/authentication/templates/authentication/login_wait_confirm.html +++ b/apps/authentication/templates/authentication/login_wait_confirm.html @@ -37,18 +37,18 @@
    -
    + -
    + -
    - + @@ -82,6 +82,7 @@ function doRequestAuth() { method: "GET", success: function (data) { if (!data.error && data.msg === 'ok') { + window.onbeforeunload = function(){}; window.location = "{% url 'authentication:login-guard' %}" } else if (data.error !== "login_confirm_wait") { if (!errorMsgShow) { @@ -125,17 +126,40 @@ function handleProgressBar() { progressBarRef.attr('aria-valuenow', offset); } +function cancelLoginConfirmTicket() { + requestApi({ + url: url, + method: "DELETE", + flash_message: false + }) +} + +function cancelCloseConfirm() { + window.onbeforeunload = function() {}; + window.onunload = function(){}; +} + +function setCloseConfirm() { + window.onbeforeunload = function (e) { + return 'Confirm'; + }; + window.onunload = function (e) { + cancelLoginConfirmTicket(); + } +} + $(document).ready(function () { interval = setInterval(handleProgressBar, 1000); checkInterval = setInterval(doRequestAuth, 5000); doRequestAuth(); initClipboard(); - window.onbeforeunload = function (e) { - return "{% trans "Confirm" %}"; - }; + setCloseConfirm(); }).on('click', '.btn-refresh', function () { - window.onbeforeunload = function() {}; + cancelCloseConfirm(); window.location.reload(); +}).on('click', '.btn-return', function () { + cancelCloseConfirm(); + window.location = "{% url 'authentication:login' %}" }) diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 94226a237..873057169 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -152,8 +152,8 @@ class UserLoginWaitConfirmView(TemplateView): ticket = get_object_or_none(LoginConfirmTicket, pk=ticket_id) context = super().get_context_data(**kwargs) if ticket: - ticket_detail_url = reverse('tickets:login-confirm-ticket-detail', kwargs={'pk': ticket_id}) timestamp_created = datetime.datetime.timestamp(ticket.date_created) + ticket_detail_url = reverse('tickets:login-confirm-ticket-detail', kwargs={'pk': ticket_id}) msg = _("""Wait for {} confirm, You also can copy link to her/him
    Don't close this page""").format(ticket.assignees_display) else: From a01126c6c7054ae4ca397d16c340aebbafcdc42d Mon Sep 17 00:00:00 2001 From: ibuler Date: Mon, 11 Nov 2019 20:10:49 +0800 Subject: [PATCH 29/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9radius=20MFA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/backends/radius.py | 19 +++++++++++++++++++ apps/jumpserver/conf.py | 2 ++ apps/users/models/user.py | 13 ++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/authentication/backends/radius.py b/apps/authentication/backends/radius.py index 6c95bf108..47f369205 100644 --- a/apps/authentication/backends/radius.py +++ b/apps/authentication/backends/radius.py @@ -5,6 +5,8 @@ from django.contrib.auth import get_user_model from radiusauth.backends import RADIUSBackend, RADIUSRealmBackend from django.conf import settings +from pyrad.packet import AccessRequest + User = get_user_model() @@ -25,6 +27,23 @@ class CreateUserMixin: user.save() return user + def _get_auth_packet(self, username, password, client): + """ + Get the pyrad authentication packet for the username/password and the + given pyrad client. + """ + pkt = client.CreateAuthPacket(code=AccessRequest, + User_Name=username) + if settings.CONFIG.RADIUS_ENCRYPT_PASSWORD: + password = pkt.PwCrypt(password) + else: + password = password + pkt["User-Password"] = password + pkt["NAS-Identifier"] = 'django-radius' + for key, val in list(getattr(settings, 'RADIUS_ATTRIBUTES', {}).items()): + pkt[key] = val + return pkt + class RadiusBackend(CreateUserMixin, RADIUSBackend): pass diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 9cacd465c..9a8a1c5f9 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -375,6 +375,7 @@ defaults = { 'RADIUS_SERVER': 'localhost', 'RADIUS_PORT': 1812, 'RADIUS_SECRET': '', + 'RADIUS_ENCRYPT_PASSWORD': True, 'AUTH_LDAP_SEARCH_PAGED_SIZE': 1000, 'AUTH_LDAP_SYNC_IS_PERIODIC': False, 'AUTH_LDAP_SYNC_INTERVAL': None, @@ -398,6 +399,7 @@ defaults = { 'FORCE_SCRIPT_NAME': '', 'LOGIN_CONFIRM_ENABLE': False, 'WINDOWS_SKIP_ALL_MANUAL_PASSWORD': False, + 'OTP_IN_RADIUS': False, } diff --git a/apps/users/models/user.py b/apps/users/models/user.py index bf8186174..e96fef6e0 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -375,9 +375,20 @@ class MFAMixin: self.otp_level = 0 self.otp_secret_key = None + def check_otp_on_radius(self, code): + from authentication.backends.radius import RadiusBackend + backend = RadiusBackend() + user = backend.authenticate(None, username=self.username, password=code) + if user: + return True + return False + def check_otp(self, code): from ..utils import check_otp_code - return check_otp_code(self.otp_secret_key, code) + if settings.CONFIG.OTP_IN_RADIUS: + return self.check_otp_on_radius(code) + else: + return check_otp_code(self.otp_secret_key, code) class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): From 0a74602df31578ef31c780c963b5701e87970314 Mon Sep 17 00:00:00 2001 From: BaiJiangJie Date: Wed, 13 Nov 2019 15:08:54 +0800 Subject: [PATCH 30/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=20LDAP=20=E5=90=8C=E6=AD=A5=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=9C=A8=E4=BB=BB=E5=8A=A1=E5=A4=96=E9=83=A8=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?running=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/settings/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/settings/api.py b/apps/settings/api.py index f55b37749..1fce9a44d 100644 --- a/apps/settings/api.py +++ b/apps/settings/api.py @@ -186,6 +186,8 @@ class LDAPUserListApi(generics.ListAPIView): sync_util = LDAPSyncUtil() # 还没有同步任务 if sync_util.task_no_start: + # 任务外部设置 task running 状态 + sync_util.set_task_status(sync_util.TASK_STATUS_IS_RUNNING) task = sync_ldap_user_task.delay() data = {'msg': 'Cache no data, sync task {} started.'.format(task.id)} return Response(data=data, status=409) From 4b2fbbfb84c3882bc0d909642bbe79fb50570c88 Mon Sep 17 00:00:00 2001 From: BaiJiangJie Date: Wed, 13 Nov 2019 18:44:59 +0800 Subject: [PATCH 31/55] =?UTF-8?q?[Update]=20=E6=B7=BB=E5=8A=A0=20MFA=20Rad?= =?UTF-8?q?ius=20py-radius=20=E6=B5=8B=E8=AF=95=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/backends/radius.py | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/authentication/backends/radius.py b/apps/authentication/backends/radius.py index 47f369205..9e0f7167b 100644 --- a/apps/authentication/backends/radius.py +++ b/apps/authentication/backends/radius.py @@ -51,3 +51,34 @@ class RadiusBackend(CreateUserMixin, RADIUSBackend): class RadiusRealmBackend(CreateUserMixin, RADIUSRealmBackend): pass + + +class TestRadiusPyRadius(object): + + def __init__(self, username, password): + self.username = username + self.password = password + self.secret = settings.CONFIG.RADIUS_SECRET + self.host = settings.CONFIG.RADIUS_SERVER + self.port = settings.CONFIG.RADIUS_PORT + + def attr_to_json(self): + attr_json = { + 'username': self.username, + 'password': self.password, + 'secret': self.secret, + 'host': self.host, + 'port': self.port + } + return attr_json + + def authenticate(self): + import radius + print('Authenticate Radius start: ') + params = self.attr_to_json() + print("Params: {}".format(params)) + r = radius.authenticate( + self.username, self.password, + self.secret, host=self.host, port=int(self.port) + ) + print("Result: {}".format(r)) From 870e04feac7eabd9b30ce8da84ee10f670d970d1 Mon Sep 17 00:00:00 2001 From: BaiJiangJie Date: Wed, 13 Nov 2019 18:46:22 +0800 Subject: [PATCH 32/55] =?UTF-8?q?[Update]=20=E6=B7=BB=E5=8A=A0=20py-radius?= =?UTF-8?q?=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index d99c6ee27..c43046d3c 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -89,3 +89,4 @@ flower==0.9.3 channels-redis==2.4.0 channels==2.3.0 daphne==2.3.0 +py-radius==2.0.2.post1 From aebf32d7be303527175624f06f5a2a2d4bbb3d48 Mon Sep 17 00:00:00 2001 From: BaiJiangJie Date: Thu, 14 Nov 2019 10:56:29 +0800 Subject: [PATCH 33/55] =?UTF-8?q?[Update]=20=E5=88=A0=E9=99=A4=20py-radius?= =?UTF-8?q?=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/backends/radius.py | 31 -------------------------- requirements/requirements.txt | 1 - 2 files changed, 32 deletions(-) diff --git a/apps/authentication/backends/radius.py b/apps/authentication/backends/radius.py index 9e0f7167b..47f369205 100644 --- a/apps/authentication/backends/radius.py +++ b/apps/authentication/backends/radius.py @@ -51,34 +51,3 @@ class RadiusBackend(CreateUserMixin, RADIUSBackend): class RadiusRealmBackend(CreateUserMixin, RADIUSRealmBackend): pass - - -class TestRadiusPyRadius(object): - - def __init__(self, username, password): - self.username = username - self.password = password - self.secret = settings.CONFIG.RADIUS_SECRET - self.host = settings.CONFIG.RADIUS_SERVER - self.port = settings.CONFIG.RADIUS_PORT - - def attr_to_json(self): - attr_json = { - 'username': self.username, - 'password': self.password, - 'secret': self.secret, - 'host': self.host, - 'port': self.port - } - return attr_json - - def authenticate(self): - import radius - print('Authenticate Radius start: ') - params = self.attr_to_json() - print("Params: {}".format(params)) - r = radius.authenticate( - self.username, self.password, - self.secret, host=self.host, port=int(self.port) - ) - print("Result: {}".format(r)) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index c43046d3c..d99c6ee27 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -89,4 +89,3 @@ flower==0.9.3 channels-redis==2.4.0 channels==2.3.0 daphne==2.3.0 -py-radius==2.0.2.post1 From 2ef487a92f117f39f8ba0e53f694efcce8020c03 Mon Sep 17 00:00:00 2001 From: ibuler Date: Thu, 14 Nov 2019 11:46:42 +0800 Subject: [PATCH 34/55] =?UTF-8?q?[Update]=20=E4=BF=AE=E6=94=B9=E6=94=B6?= =?UTF-8?q?=E9=9B=86=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0043_auto_20191114_1111.py | 23 +++ apps/assets/models/gathered_user.py | 11 +- apps/assets/serializers/gathered_user.py | 1 + apps/assets/tasks/const.py | 12 +- apps/assets/tasks/gather_asset_users.py | 41 +++-- apps/assets/templates/assets/_node_tree.html | 20 ++- apps/assets/templates/assets/asset_list.html | 6 +- apps/locale/zh/LC_MESSAGES/django.mo | Bin 84436 -> 84580 bytes apps/locale/zh/LC_MESSAGES/django.po | 157 ++++++++++-------- apps/ops/models/adhoc.py | 25 ++- .../perms/asset_permission_list.html | 18 +- .../migrations/0002_auto_20191114_1105.py | 24 +++ 12 files changed, 213 insertions(+), 125 deletions(-) create mode 100644 apps/assets/migrations/0043_auto_20191114_1111.py create mode 100644 apps/tickets/migrations/0002_auto_20191114_1105.py diff --git a/apps/assets/migrations/0043_auto_20191114_1111.py b/apps/assets/migrations/0043_auto_20191114_1111.py new file mode 100644 index 000000000..a07dee6bb --- /dev/null +++ b/apps/assets/migrations/0043_auto_20191114_1111.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.5 on 2019-11-14 03:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0042_favoriteasset'), + ] + + operations = [ + migrations.AddField( + model_name='gathereduser', + name='date_last_login', + field=models.DateTimeField(null=True, verbose_name='Date last login'), + ), + migrations.AddField( + model_name='gathereduser', + name='ip_last_login', + field=models.CharField(default='', max_length=39, verbose_name='IP last login'), + ), + ] diff --git a/apps/assets/models/gathered_user.py b/apps/assets/models/gathered_user.py index 282f9293a..d00021c56 100644 --- a/apps/assets/models/gathered_user.py +++ b/apps/assets/models/gathered_user.py @@ -12,13 +12,12 @@ __all__ = ['GatheredUser'] class GatheredUser(OrgModelMixin): id = models.UUIDField(default=uuid.uuid4, primary_key=True) asset = models.ForeignKey('assets.Asset', on_delete=models.CASCADE, verbose_name=_("Asset")) - username = models.CharField(max_length=32, blank=True, db_index=True, - verbose_name=_('Username')) + username = models.CharField(max_length=32, blank=True, db_index=True, verbose_name=_('Username')) present = models.BooleanField(default=True, verbose_name=_("Present")) - date_created = models.DateTimeField(auto_now_add=True, - verbose_name=_("Date created")) - date_updated = models.DateTimeField(auto_now=True, - verbose_name=_("Date updated")) + date_last_login = models.DateTimeField(null=True, verbose_name=_("Date last login")) + ip_last_login = models.CharField(max_length=39, default='', verbose_name=_("IP last login")) + date_created = models.DateTimeField(auto_now_add=True, verbose_name=_("Date created")) + date_updated = models.DateTimeField(auto_now=True, verbose_name=_("Date updated")) @property def hostname(self): diff --git a/apps/assets/serializers/gathered_user.py b/apps/assets/serializers/gathered_user.py index 956c19c6b..c055e25bd 100644 --- a/apps/assets/serializers/gathered_user.py +++ b/apps/assets/serializers/gathered_user.py @@ -12,6 +12,7 @@ class GatheredUserSerializer(OrgResourceModelSerializerMixin): model = GatheredUser fields = [ 'id', 'asset', 'hostname', 'ip', 'username', + 'date_last_login', 'ip_last_login', 'present', 'date_created', 'date_updated' ] read_only_fields = fields diff --git a/apps/assets/tasks/const.py b/apps/assets/tasks/const.py index 6810a00bc..5b7db13cd 100644 --- a/apps/assets/tasks/const.py +++ b/apps/assets/tasks/const.py @@ -7,10 +7,7 @@ from django.utils.translation import ugettext_lazy as _ ENV_PERIOD_TASK = os.environ.get("PERIOD_TASK", "on") == 'on' -ENV_PERIOD_TASK_ENABLED = os.environ.get("PERIOD_TASK_ENABLED", "on") == "on" -PERIOD_TASK_ENABLED = settings.CONFIG.PERIOD_TASK_ENABLE \ - and ENV_PERIOD_TASK \ - and ENV_PERIOD_TASK_ENABLED +PERIOD_TASK_ENABLED = settings.PERIOD_TASK_ENABLED and ENV_PERIOD_TASK UPDATE_ASSETS_HARDWARE_TASKS = [ { @@ -97,6 +94,13 @@ GATHER_ASSET_USERS_TASKS = [ "args": "database=passwd" }, }, + { + "name": "get last login", + "action": { + "module": "shell", + "args": "users=$(getent passwd | grep -v 'nologin' | grep -v 'shudown' | awk -F: '{ print $1 }');for i in $users;do last -F $i -1 | head -1 | grep -v '^$' | awk '{ print $1\"@\"$3\"@\"$5,$6,$7,$8 }';done" + } + } ] GATHER_ASSET_USERS_TASKS_WINDOWS = [ diff --git a/apps/assets/tasks/gather_asset_users.py b/apps/assets/tasks/gather_asset_users.py index efeecc25e..7dfe0fb01 100644 --- a/apps/assets/tasks/gather_asset_users.py +++ b/apps/assets/tasks/gather_asset_users.py @@ -2,9 +2,10 @@ import re from collections import defaultdict -from celery import shared_task +from celery import shared_task from django.utils.translation import ugettext as _ +from django.utils import timezone from orgs.utils import tmp_to_org from common.utils import get_logger @@ -19,19 +20,25 @@ ignore_login_shell = re.compile(r'nologin$|sync$|shutdown$|halt$') def parse_linux_result_to_users(result): - task_result = {} - for task_name, raw in result.items(): - res = raw.get('ansible_facts', {}).get('getent_passwd') - if res: - task_result = res - break - if not task_result or not isinstance(task_result, dict): - return [] - users = [] - for username, attr in task_result.items(): + users = defaultdict(dict) + users_result = result.get('gather host users', {})\ + .get('ansible_facts', {})\ + .get('getent_passwd') + if not isinstance(users_result, dict): + users_result = {} + for username, attr in users_result.items(): if ignore_login_shell.search(attr[-1]): continue - users.append(username) + users[username] = {} + last_login_result = result.get('get last login', {}).get('stdout_lines', []) + for line in last_login_result: + data = line.split('@') + if len(data) != 3: + continue + username, ip, dt = data + dt += ' +0800' + date = timezone.datetime.strptime(dt, '%b %d %H:%M:%S %Y %z') + users[username] = {"ip": ip, "date": date} return users @@ -45,7 +52,7 @@ def parse_windows_result_to_users(result): if not task_result: return [] - users = [] + users = {} for i in range(4): task_result.pop(0) @@ -55,7 +62,7 @@ def parse_windows_result_to_users(result): for line in task_result: user = space.split(line) if user[0]: - users.append(user[0]) + users[user[0]] = {} return users @@ -82,8 +89,12 @@ def add_asset_users(assets, results): with tmp_to_org(asset.org_id): GatheredUser.objects.filter(asset=asset, present=True)\ .update(present=False) - for username in users: + for username, data in users.items(): defaults = {'asset': asset, 'username': username, 'present': True} + if data.get("ip"): + defaults["ip_last_login"] = data["ip"] + if data.get("date"): + defaults["date_last_login"] = data["date"] GatheredUser.objects.update_or_create( defaults=defaults, asset=asset, username=username, ) diff --git a/apps/assets/templates/assets/_node_tree.html b/apps/assets/templates/assets/_node_tree.html index 803c29c13..fc7f4ae06 100644 --- a/apps/assets/templates/assets/_node_tree.html +++ b/apps/assets/templates/assets/_node_tree.html @@ -303,9 +303,24 @@ function defaultCallback(action) { return logging } +function toggle() { + if (show === 0) { + $("#split-left").hide(500, function () { + $("#split-right").attr("class", "col-lg-12"); + $("#toggle-icon").attr("class", "fa fa-angle-right fa-x"); + show = 1; + }); + } else { + $("#split-right").attr("class", "col-lg-9"); + $("#toggle-icon").attr("class", "fa fa-angle-left fa-x"); + $("#split-left").show(500); + show = 0; + } +} + $(document).ready(function () { - $('.treebox').css('height', window.innerHeight - 180); + $('.treebox').css('height', window.innerHeight - 60); }) .on('click', '.btn-show-current-asset', function(){ hideRMenu(); @@ -320,6 +335,9 @@ $(document).ready(function () { $('#show_current_asset').css('display', 'inline-block'); setCookie('show_current_asset', ''); location.reload(); +}).on('click', '.tree-toggle-btn', function (e) { + e.preventDefault(); + toggle(); }) diff --git a/apps/assets/templates/assets/asset_list.html b/apps/assets/templates/assets/asset_list.html index fcc17c87d..c17b50b1c 100644 --- a/apps/assets/templates/assets/asset_list.html +++ b/apps/assets/templates/assets/asset_list.html @@ -48,12 +48,12 @@ {% block content %}
    -
    +
    {% include 'assets/_node_tree.html' %}
    -
    -
    +
    +
    diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index b32d5361009254cb136db5675ecb4f8c6c9fe795..d5225f406774ca2d4319ee463b3b466bc79c53aa 100644 GIT binary patch delta 24523 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\n" "Language-Team: Jumpserver team\n" @@ -96,7 +96,7 @@ msgstr "运行参数" #: terminal/templates/terminal/session_list.html:28 #: terminal/templates/terminal/session_list.html:72 #: xpack/plugins/change_auth_plan/forms.py:73 -#: xpack/plugins/change_auth_plan/models.py:419 +#: xpack/plugins/change_auth_plan/models.py:412 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_create_update.html:46 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_list.html:54 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:13 @@ -152,7 +152,7 @@ msgstr "资产" #: users/templates/users/user_profile.html:51 #: users/templates/users/user_pubkey_update.html:57 #: xpack/plugins/change_auth_plan/forms.py:56 -#: xpack/plugins/change_auth_plan/models.py:64 +#: xpack/plugins/change_auth_plan/models.py:63 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:61 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_list.html:12 #: xpack/plugins/cloud/models.py:59 xpack/plugins/cloud/models.py:144 @@ -199,7 +199,7 @@ msgstr "参数" #: perms/templates/perms/remote_app_permission_detail.html:90 #: users/models/user.py:451 users/serializers/group.py:32 #: users/templates/users/user_detail.html:112 -#: xpack/plugins/change_auth_plan/models.py:109 +#: xpack/plugins/change_auth_plan/models.py:108 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:113 #: xpack/plugins/cloud/models.py:80 xpack/plugins/cloud/models.py:179 #: xpack/plugins/gathered_user/models.py:46 @@ -264,7 +264,7 @@ msgstr "创建日期" #: users/templates/users/user_group_detail.html:67 #: users/templates/users/user_group_list.html:37 #: users/templates/users/user_profile.html:138 -#: xpack/plugins/change_auth_plan/models.py:105 +#: xpack/plugins/change_auth_plan/models.py:104 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:117 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_list.html:19 #: xpack/plugins/cloud/models.py:77 xpack/plugins/cloud/models.py:173 @@ -607,7 +607,7 @@ msgstr "端口" #: assets/templates/assets/asset_detail.html:196 #: assets/templates/assets/system_user_assets.html:83 #: perms/models/asset_permission.py:81 -#: xpack/plugins/change_auth_plan/models.py:75 +#: xpack/plugins/change_auth_plan/models.py:74 #: xpack/plugins/gathered_user/models.py:31 #: xpack/plugins/gathered_user/templates/gathered_user/task_list.html:17 msgid "Nodes" @@ -693,7 +693,7 @@ msgid "SSH gateway support proxy SSH,RDP,VNC" msgstr "SSH网关,支持代理SSH,RDP和VNC" #: assets/forms/domain.py:78 assets/forms/user.py:76 assets/forms/user.py:96 -#: assets/models/base.py:29 assets/models/gathered_user.py:16 +#: assets/models/base.py:29 assets/models/gathered_user.py:15 #: assets/templates/assets/_asset_user_auth_update_modal.html:15 #: assets/templates/assets/_asset_user_auth_view_modal.html:21 #: assets/templates/assets/_asset_user_list.html:21 @@ -714,13 +714,13 @@ msgstr "SSH网关,支持代理SSH,RDP和VNC" #: users/templates/users/user_list.html:36 #: users/templates/users/user_profile.html:47 #: xpack/plugins/change_auth_plan/forms.py:58 -#: xpack/plugins/change_auth_plan/models.py:66 -#: xpack/plugins/change_auth_plan/models.py:415 +#: xpack/plugins/change_auth_plan/models.py:65 +#: xpack/plugins/change_auth_plan/models.py:408 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:65 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_list.html:53 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:12 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_list.html:13 -#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:74 +#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:64 msgid "Username" msgstr "用户名" @@ -742,8 +742,8 @@ msgstr "密码或密钥密码" #: users/templates/users/user_profile_update.html:41 #: users/templates/users/user_pubkey_update.html:41 #: users/templates/users/user_update.html:20 -#: xpack/plugins/change_auth_plan/models.py:96 -#: xpack/plugins/change_auth_plan/models.py:264 +#: xpack/plugins/change_auth_plan/models.py:95 +#: xpack/plugins/change_auth_plan/models.py:263 msgid "Password" msgstr "密码" @@ -801,12 +801,12 @@ msgstr "使用逗号分隔多个命令,如: /bin/whoami,/sbin/ifconfig" #: perms/templates/perms/asset_permission_asset.html:58 settings/forms.py:144 #: users/templates/users/_granted_assets.html:31 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_asset_list.html:54 -#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:73 +#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:63 msgid "IP" msgstr "IP" #: assets/models/asset.py:136 assets/serializers/asset_user.py:27 -#: assets/serializers/gathered_user.py:19 +#: assets/serializers/gathered_user.py:20 #: assets/templates/assets/_asset_list_modal.html:46 #: assets/templates/assets/_asset_user_auth_update_modal.html:9 #: assets/templates/assets/_asset_user_auth_view_modal.html:15 @@ -818,7 +818,7 @@ msgstr "IP" #: perms/templates/perms/asset_permission_list.html:73 settings/forms.py:143 #: users/templates/users/_granted_assets.html:30 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_asset_list.html:53 -#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:72 +#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:62 msgid "Hostname" msgstr "主机名" @@ -937,21 +937,21 @@ msgstr "版本" msgid "AuthBook" msgstr "" -#: assets/models/base.py:31 xpack/plugins/change_auth_plan/models.py:100 -#: xpack/plugins/change_auth_plan/models.py:271 +#: assets/models/base.py:31 xpack/plugins/change_auth_plan/models.py:99 +#: xpack/plugins/change_auth_plan/models.py:270 msgid "SSH private key" msgstr "ssh密钥" -#: assets/models/base.py:32 xpack/plugins/change_auth_plan/models.py:103 -#: xpack/plugins/change_auth_plan/models.py:267 +#: assets/models/base.py:32 xpack/plugins/change_auth_plan/models.py:102 +#: xpack/plugins/change_auth_plan/models.py:266 msgid "SSH public key" msgstr "ssh公钥" -#: assets/models/base.py:35 assets/models/gathered_user.py:21 +#: assets/models/base.py:35 assets/models/gathered_user.py:20 #: assets/templates/assets/cmd_filter_detail.html:73 common/mixins/models.py:52 #: ops/models/adhoc.py:46 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:109 -#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:76 +#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:68 msgid "Date updated" msgstr "更新日期" @@ -1080,12 +1080,22 @@ msgstr "命令过滤规则" msgid "Gateway" msgstr "网关" -#: assets/models/gathered_user.py:17 -#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:75 +#: assets/models/gathered_user.py:16 +#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:67 msgid "Present" msgstr "存在" -#: assets/models/gathered_user.py:32 +#: assets/models/gathered_user.py:17 +#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:65 +msgid "Date last login" +msgstr "最后登录日期" + +#: assets/models/gathered_user.py:18 +#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:66 +msgid "IP last login" +msgstr "最后登录IP" + +#: assets/models/gathered_user.py:31 msgid "GatherUser" msgstr "收集用户" @@ -1188,7 +1198,7 @@ msgstr "手动登录" #: assets/views/label.py:27 assets/views/label.py:45 assets/views/label.py:73 #: assets/views/system_user.py:29 assets/views/system_user.py:46 #: assets/views/system_user.py:63 assets/views/system_user.py:79 -#: templates/_nav.html:39 xpack/plugins/change_auth_plan/models.py:71 +#: templates/_nav.html:39 xpack/plugins/change_auth_plan/models.py:70 msgid "Assets" msgstr "资产管理" @@ -1237,16 +1247,16 @@ msgstr "系统用户" msgid "%(value)s is not an even number" msgstr "%(value)s is not an even number" -#: assets/models/utils.py:43 assets/tasks/const.py:87 +#: assets/models/utils.py:43 assets/tasks/const.py:84 msgid "Unreachable" msgstr "不可达" -#: assets/models/utils.py:44 assets/tasks/const.py:88 +#: assets/models/utils.py:44 assets/tasks/const.py:85 #: assets/templates/assets/asset_list.html:99 msgid "Reachable" msgstr "可连接" -#: assets/models/utils.py:45 assets/tasks/const.py:89 audits/utils.py:30 +#: assets/models/utils.py:45 assets/tasks/const.py:86 audits/utils.py:30 #: xpack/plugins/license/models.py:78 msgid "Unknown" msgstr "未知" @@ -1333,7 +1343,7 @@ msgstr "测试资产可连接性: {}" #: assets/tasks/asset_user_connectivity.py:27 #: assets/tasks/push_system_user.py:130 -#: xpack/plugins/change_auth_plan/models.py:528 +#: xpack/plugins/change_auth_plan/models.py:521 msgid "The asset {} system platform {} does not support run Ansible tasks" msgstr "资产 {} 系统平台 {} 不支持运行 Ansible 任务" @@ -1353,7 +1363,7 @@ msgstr "更新资产硬件信息" msgid "Update asset hardware info: {}" msgstr "更新资产硬件信息: {}" -#: assets/tasks/gather_asset_users.py:96 +#: assets/tasks/gather_asset_users.py:107 msgid "Gather assets users" msgstr "收集资产上的用户" @@ -1706,7 +1716,7 @@ msgstr "Jumpserver 使用该用户来 `推送系统用户`、`获取资产硬件 #: audits/templates/audits/login_log_list.html:91 #: users/templates/users/user_group_list.html:10 #: users/templates/users/user_list.html:10 -#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:59 +#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:49 #: xpack/plugins/vault/templates/vault/vault.html:55 msgid "Export" msgstr "导出" @@ -2288,7 +2298,7 @@ msgid "MFA" msgstr "MFA" #: audits/models.py:87 audits/templates/audits/login_log_list.html:63 -#: xpack/plugins/change_auth_plan/models.py:423 +#: xpack/plugins/change_auth_plan/models.py:416 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:15 #: xpack/plugins/cloud/models.py:278 #: xpack/plugins/cloud/templates/cloud/sync_instance_task_history.html:69 @@ -2317,11 +2327,11 @@ msgstr "登录日期" #: perms/templates/perms/asset_permission_detail.html:86 #: perms/templates/perms/remote_app_permission_detail.html:78 #: terminal/models.py:167 terminal/templates/terminal/session_list.html:34 -#: xpack/plugins/change_auth_plan/models.py:250 -#: xpack/plugins/change_auth_plan/models.py:426 +#: xpack/plugins/change_auth_plan/models.py:249 +#: xpack/plugins/change_auth_plan/models.py:419 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_list.html:59 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:17 -#: xpack/plugins/gathered_user/models.py:143 +#: xpack/plugins/gathered_user/models.py:140 msgid "Date start" msgstr "开始日期" @@ -2888,49 +2898,49 @@ msgstr "Become" msgid "Create by" msgstr "创建者" -#: ops/models/adhoc.py:252 +#: ops/models/adhoc.py:254 msgid "{} Start task: {}" msgstr "{} 任务开始: {}" -#: ops/models/adhoc.py:264 +#: ops/models/adhoc.py:263 msgid "{} Task finish" msgstr "{} 任务结束" -#: ops/models/adhoc.py:356 +#: ops/models/adhoc.py:360 msgid "Start time" msgstr "开始时间" -#: ops/models/adhoc.py:357 +#: ops/models/adhoc.py:361 msgid "End time" msgstr "完成时间" -#: ops/models/adhoc.py:358 ops/templates/ops/adhoc_history.html:57 +#: ops/models/adhoc.py:362 ops/templates/ops/adhoc_history.html:57 #: ops/templates/ops/task_history.html:63 ops/templates/ops/task_list.html:17 -#: xpack/plugins/change_auth_plan/models.py:253 -#: xpack/plugins/change_auth_plan/models.py:429 +#: xpack/plugins/change_auth_plan/models.py:252 +#: xpack/plugins/change_auth_plan/models.py:422 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_list.html:58 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_subtask_list.html:16 -#: xpack/plugins/gathered_user/models.py:146 +#: xpack/plugins/gathered_user/models.py:143 msgid "Time" msgstr "时间" -#: ops/models/adhoc.py:359 ops/templates/ops/adhoc_detail.html:106 +#: ops/models/adhoc.py:363 ops/templates/ops/adhoc_detail.html:106 #: ops/templates/ops/adhoc_history.html:55 #: ops/templates/ops/adhoc_history_detail.html:69 #: ops/templates/ops/task_detail.html:84 ops/templates/ops/task_history.html:61 msgid "Is finished" msgstr "是否完成" -#: ops/models/adhoc.py:360 ops/templates/ops/adhoc_history.html:56 +#: ops/models/adhoc.py:364 ops/templates/ops/adhoc_history.html:56 #: ops/templates/ops/task_history.html:62 msgid "Is success" msgstr "是否成功" -#: ops/models/adhoc.py:361 +#: ops/models/adhoc.py:365 msgid "Adhoc raw result" msgstr "结果" -#: ops/models/adhoc.py:362 +#: ops/models/adhoc.py:366 msgid "Adhoc result summary" msgstr "汇总" @@ -3350,7 +3360,7 @@ msgstr "刷新授权缓存" msgid "Validity" msgstr "有效" -#: perms/templates/perms/asset_permission_list.html:244 +#: perms/templates/perms/asset_permission_list.html:230 msgid "Refresh success" msgstr "刷新成功" @@ -4832,7 +4842,7 @@ msgstr "生成重置密码链接,通过邮件发送给用户" msgid "Set password" msgstr "设置密码" -#: users/forms.py:152 xpack/plugins/change_auth_plan/models.py:89 +#: users/forms.py:152 xpack/plugins/change_auth_plan/models.py:88 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_create_update.html:51 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:69 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_execution_list.html:57 @@ -5762,8 +5772,8 @@ msgstr "" "具)
    注意: 如果同时设置了定期执行和周期执行,优先使用定期执行" #: xpack/plugins/change_auth_plan/meta.py:9 -#: xpack/plugins/change_auth_plan/models.py:117 -#: xpack/plugins/change_auth_plan/models.py:257 +#: xpack/plugins/change_auth_plan/models.py:116 +#: xpack/plugins/change_auth_plan/models.py:256 #: xpack/plugins/change_auth_plan/views.py:33 #: xpack/plugins/change_auth_plan/views.py:50 #: xpack/plugins/change_auth_plan/views.py:74 @@ -5774,20 +5784,20 @@ msgstr "" msgid "Change auth plan" msgstr "改密计划" -#: xpack/plugins/change_auth_plan/models.py:58 +#: xpack/plugins/change_auth_plan/models.py:57 msgid "Custom password" msgstr "自定义密码" -#: xpack/plugins/change_auth_plan/models.py:59 +#: xpack/plugins/change_auth_plan/models.py:58 msgid "All assets use the same random password" msgstr "所有资产使用相同的随机密码" -#: xpack/plugins/change_auth_plan/models.py:60 +#: xpack/plugins/change_auth_plan/models.py:59 msgid "All assets use different random password" msgstr "所有资产使用不同的随机密码" -#: xpack/plugins/change_auth_plan/models.py:79 -#: xpack/plugins/change_auth_plan/models.py:148 +#: xpack/plugins/change_auth_plan/models.py:78 +#: xpack/plugins/change_auth_plan/models.py:147 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:100 #: xpack/plugins/cloud/models.py:165 xpack/plugins/cloud/models.py:219 #: xpack/plugins/cloud/templates/cloud/sync_instance_task_detail.html:91 @@ -5796,8 +5806,8 @@ msgstr "所有资产使用不同的随机密码" msgid "Cycle perform" msgstr "周期执行" -#: xpack/plugins/change_auth_plan/models.py:84 -#: xpack/plugins/change_auth_plan/models.py:146 +#: xpack/plugins/change_auth_plan/models.py:83 +#: xpack/plugins/change_auth_plan/models.py:145 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:92 #: xpack/plugins/cloud/models.py:170 xpack/plugins/cloud/models.py:217 #: xpack/plugins/cloud/templates/cloud/sync_instance_task_detail.html:83 @@ -5806,37 +5816,37 @@ msgstr "周期执行" msgid "Regularly perform" msgstr "定期执行" -#: xpack/plugins/change_auth_plan/models.py:93 +#: xpack/plugins/change_auth_plan/models.py:92 #: xpack/plugins/change_auth_plan/templates/change_auth_plan/plan_detail.html:74 msgid "Password rules" msgstr "密码规则" -#: xpack/plugins/change_auth_plan/models.py:213 +#: xpack/plugins/change_auth_plan/models.py:212 msgid "* For security, do not change {} user's password" msgstr "* 为了安全,禁止更改 {} 用户的密码" -#: xpack/plugins/change_auth_plan/models.py:217 +#: xpack/plugins/change_auth_plan/models.py:216 msgid "Assets is empty, please add the asset" msgstr "资产为空,请添加资产" -#: xpack/plugins/change_auth_plan/models.py:261 +#: xpack/plugins/change_auth_plan/models.py:260 msgid "Change auth plan snapshot" msgstr "改密计划快照" -#: xpack/plugins/change_auth_plan/models.py:276 -#: xpack/plugins/change_auth_plan/models.py:433 +#: xpack/plugins/change_auth_plan/models.py:275 +#: xpack/plugins/change_auth_plan/models.py:426 msgid "Change auth plan execution" msgstr "改密计划执行" -#: xpack/plugins/change_auth_plan/models.py:442 +#: xpack/plugins/change_auth_plan/models.py:435 msgid "Change auth plan execution subtask" msgstr "改密计划执行子任务" -#: xpack/plugins/change_auth_plan/models.py:460 +#: xpack/plugins/change_auth_plan/models.py:453 msgid "Authentication failed" msgstr "认证失败" -#: xpack/plugins/change_auth_plan/models.py:462 +#: xpack/plugins/change_auth_plan/models.py:455 msgid "Connection timeout" msgstr "连接超时" @@ -6186,19 +6196,19 @@ msgid "Periodic" msgstr "定时执行" #: xpack/plugins/gathered_user/models.py:57 -#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:48 +#: xpack/plugins/gathered_user/templates/gathered_user/gathered_user_list.html:38 msgid "Gather user task" msgstr "收集用户任务" -#: xpack/plugins/gathered_user/models.py:140 +#: xpack/plugins/gathered_user/models.py:137 msgid "Task" msgstr "任务" -#: xpack/plugins/gathered_user/models.py:152 +#: xpack/plugins/gathered_user/models.py:149 msgid "gather user task execution" msgstr "收集用户执行" -#: xpack/plugins/gathered_user/models.py:158 +#: xpack/plugins/gathered_user/models.py:155 msgid "Assets is empty, please change nodes" msgstr "资产为空,请更改节点" @@ -6446,6 +6456,11 @@ msgstr "密码匣子" msgid "vault create" msgstr "创建" +#, fuzzy +#~| msgid "Login" +#~ msgid "Login IP" +#~ msgstr "登录" + #~ msgid "succeed: {} failed: {} total: {}" #~ msgstr "成功:{} 失败:{} 总数:{}" diff --git a/apps/ops/models/adhoc.py b/apps/ops/models/adhoc.py index b9c1c4a74..3eebb6636 100644 --- a/apps/ops/models/adhoc.py +++ b/apps/ops/models/adhoc.py @@ -246,30 +246,35 @@ class AdHoc(models.Model): time_start = time.time() date_start = timezone.now() is_success = False + summary = {} + raw = '' try: date_start_s = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') print(_("{} Start task: {}").format(date_start_s, self.task.name)) raw, summary = self._run_only() is_success = summary.get('success', False) - return raw, summary except Exception as e: logger.error(e, exc_info=True) - summary = {} raw = {"dark": {"all": str(e)}, "contacted": []} - return raw, summary finally: date_end = timezone.now() date_end_s = date_end.strftime('%Y-%m-%d %H:%M:%S') print(_("{} Task finish").format(date_end_s)) print('.\n\n.') + try: + summary_text = json.dumps(summary) + except json.JSONDecodeError: + summary_text = '{}' AdHocRunHistory.objects.filter(id=history.id).update( date_start=date_start, is_finished=True, is_success=is_success, date_finished=timezone.now(), - timedelta=time.time() - time_start + timedelta=time.time() - time_start, + _summary=summary_text ) + return raw, summary def _run_only(self): Task.objects.filter(id=self.task.id).update(date_updated=timezone.now()) @@ -321,10 +326,9 @@ class AdHoc(models.Model): except AdHocRunHistory.DoesNotExist: return None - def save(self, force_insert=False, force_update=False, using=None, - update_fields=None): - super().save(force_insert=force_insert, force_update=force_update, - using=using, update_fields=update_fields) + def save(self, **kwargs): + instance = super().save(**kwargs) + return instance def __str__(self): return "{} of {}".format(self.task.name, self.short_id) @@ -393,7 +397,10 @@ class AdHocRunHistory(models.Model): @summary.setter def summary(self, item): - self._summary = json.dumps(item) + try: + self._summary = json.dumps(item) + except json.JSONDecodeError: + self._summary = json.dumps({}) @property def success_hosts(self): diff --git a/apps/perms/templates/perms/asset_permission_list.html b/apps/perms/templates/perms/asset_permission_list.html index 18e30bde8..57079b4f8 100644 --- a/apps/perms/templates/perms/asset_permission_list.html +++ b/apps/perms/templates/perms/asset_permission_list.html @@ -23,12 +23,12 @@ {% block content %}
    -
    +
    {% include 'assets/_node_tree.html' %}
    -
    +
    @@ -209,20 +209,6 @@ function initTree() { }) } -function toggle() { - if (show === 0) { - $("#split-left").hide(500, function () { - $("#split-right").attr("class", "col-lg-12"); - $("#toggle-icon").attr("class", "fa fa-angle-right fa-x"); - show = 1; - }); - } else { - $("#split-right").attr("class", "col-lg-9"); - $("#toggle-icon").attr("class", "fa fa-angle-left fa-x"); - $("#split-left").show(500); - show = 0; - } -} $(document).ready(function(){ initTable(); diff --git a/apps/tickets/migrations/0002_auto_20191114_1105.py b/apps/tickets/migrations/0002_auto_20191114_1105.py new file mode 100644 index 000000000..87cec4056 --- /dev/null +++ b/apps/tickets/migrations/0002_auto_20191114_1105.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.5 on 2019-11-14 03:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='comment', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='tickets.Ticket'), + ), + migrations.AlterField( + model_name='ticket', + name='type', + field=models.CharField(choices=[('general', 'General'), ('login_confirm', 'Login confirm')], default='general', max_length=16, verbose_name='Type'), + ), + ] From 8dba54e7c57c86ff1108c74d9f90b841fa459b39 Mon Sep 17 00:00:00 2001 From: ibuler Date: Thu, 14 Nov 2019 12:12:34 +0800 Subject: [PATCH 35/55] =?UTF-8?q?[Update]=20=E4=BC=98=E5=8C=96table?= =?UTF-8?q?=E9=A1=B5=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/assets/_asset_list_modal.html | 3 ++- apps/assets/templates/assets/asset_list.html | 16 ++++++++-------- apps/static/js/jumpserver.js | 14 +++++++++++++- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/assets/templates/assets/_asset_list_modal.html b/apps/assets/templates/assets/_asset_list_modal.html index 4c6eb7199..7c3e03f17 100644 --- a/apps/assets/templates/assets/_asset_list_modal.html +++ b/apps/assets/templates/assets/_asset_list_modal.html @@ -135,7 +135,8 @@ function initAssetModalTable() { ], lengthMenu: [[10, 25, 50], [10, 25, 50]], pageLength: 10, - select_style: assetModalOption.selectStyle + select_style: assetModalOption.selectStyle, + paging_numbers_length: 3 }; assetModalTable = jumpserver.initServerSideDataTable(options); if (assetModalOption.onModalTableDone) { diff --git a/apps/assets/templates/assets/asset_list.html b/apps/assets/templates/assets/asset_list.html index c17b50b1c..206b6b7dd 100644 --- a/apps/assets/templates/assets/asset_list.html +++ b/apps/assets/templates/assets/asset_list.html @@ -386,6 +386,10 @@ $(document).ready(function(){ setTimeout( function () {window.location.reload();}, 300); } + function reloadTable() { + asset_table.ajax.reload(); + } + function doDeactive() { var data = []; $.each(id_list, function(index, object_id) { @@ -396,7 +400,7 @@ $(document).ready(function(){ url: the_url, method: 'PATCH', body: JSON.stringify(data), - success: refreshPage + success: reloadTable }); } function doActive() { @@ -409,7 +413,7 @@ $(document).ready(function(){ url: the_url, method: 'PATCH', body: JSON.stringify(data), - success: refreshPage + success: reloadTable }); } function doDelete() { @@ -431,7 +435,7 @@ $(document).ready(function(){ success: function () { var msg = "{% trans 'Asset Deleted.' %}"; swal("{% trans 'Asset Delete' %}", msg, "success"); - refreshPage(); + reloadTable(); }, flash_message: false, }); @@ -478,16 +482,12 @@ $(document).ready(function(){ 'assets': id_list }; - var success = function () { - asset_table.ajax.reload() - }; var url = "{% url 'api-assets:node-remove-assets' pk=DEFAULT_PK %}".replace("{{ DEFAULT_PK }}", current_node_id); - requestApi({ 'url': url, 'method': 'PUT', 'body': JSON.stringify(data), - 'success': success + 'success': reloadTable }) } diff --git a/apps/static/js/jumpserver.js b/apps/static/js/jumpserver.js index b16d3cdfc..fb12b73b7 100644 --- a/apps/static/js/jumpserver.js +++ b/apps/static/js/jumpserver.js @@ -480,6 +480,11 @@ jumpserver.language = { last: "»" } }; + +function setDataTablePagerLength(num) { + $.fn.DataTable.ext.pager.numbers_length = num; +} + jumpserver.initDataTable = function (options) { // options = { // ele *: $('#dataTable_id'), @@ -494,6 +499,7 @@ jumpserver.initDataTable = function (options) { // op_html: 'div.btn-group?', // paging: true // } + setDataTablePagerLength(5); var ele = options.ele || $('.dataTable'); var columnDefs = [ { @@ -590,8 +596,14 @@ jumpserver.initServerSideDataTable = function (options) { // columnDefs: [{target: 0, createdCell: ()=>{}}, ...], // uc_html: 'header button', // op_html: 'div.btn-group?', - // paging: true + // paging: true, + // paging_numbers_length: 5; // } + var pagingNumbersLength = 5; + if (options.paging_numbers_length){ + pagingNumbersLength = options.paging_numbers_length; + } + setDataTablePagerLength(pagingNumbersLength); var ele = options.ele || $('.dataTable'); var columnDefs = [ { From 611d5fc1fa933ea6ef40354c258a40c0d9271445 Mon Sep 17 00:00:00 2001 From: ibuler Date: Thu, 14 Nov 2019 14:53:57 +0800 Subject: [PATCH 36/55] =?UTF-8?q?[Update]=20Message=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../applications/remote_app_list.html | 4 +-- .../templates/assets/admin_user_list.html | 2 -- apps/assets/templates/assets/asset_list.html | 10 +++---- .../templates/assets/cmd_filter_list.html | 2 -- apps/assets/templates/assets/domain_list.html | 4 --- .../templates/assets/system_user_list.html | 2 -- apps/static/css/style.css | 6 ++--- apps/templates/base.html | 26 +++++++++++++++++-- 8 files changed, 33 insertions(+), 23 deletions(-) diff --git a/apps/applications/templates/applications/remote_app_list.html b/apps/applications/templates/applications/remote_app_list.html index 3dbe4f8eb..a308bf911 100644 --- a/apps/applications/templates/applications/remote_app_list.html +++ b/apps/applications/templates/applications/remote_app_list.html @@ -1,10 +1,8 @@ {% extends '_base_list.html' %} {% load i18n static %} {% block help_message %} -
    {% trans 'Before using this feature, make sure that the application loader has been uploaded to the application server and successfully published as a RemoteApp application' %} {% trans 'Download application loader' %} -
    {% endblock %} {% block table_search %}{% endblock %} {% block table_container %} @@ -84,4 +82,4 @@ $(document).ready(function(){ }, 3000); }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/apps/assets/templates/assets/admin_user_list.html b/apps/assets/templates/assets/admin_user_list.html index 7a4b2a2dc..8063d6de6 100644 --- a/apps/assets/templates/assets/admin_user_list.html +++ b/apps/assets/templates/assets/admin_user_list.html @@ -1,10 +1,8 @@ {% extends '_base_list.html' %} {% load i18n static %} {% block help_message %} -
    {% trans 'Admin users are asset (charged server) on the root, or have NOPASSWD: ALL sudo permissions users, '%} {% trans 'Jumpserver users of the system using the user to `push system user`, `get assets hardware information`, etc. '%} -
    {% endblock %} {% block table_search %}
    diff --git a/apps/assets/templates/assets/asset_list.html b/apps/assets/templates/assets/asset_list.html index 206b6b7dd..486433ad9 100644 --- a/apps/assets/templates/assets/asset_list.html +++ b/apps/assets/templates/assets/asset_list.html @@ -3,16 +3,16 @@ {% load i18n %} {% block help_message %} -
    +{#
    #} +{# #} {# 左侧是资产树,右击可以新建、删除、更改树节点,授权资产也是以节点方式组织的,右侧是属于该节点下的资产#} {% trans 'The left side is the asset tree, right click to create, delete, and change the tree node, authorization asset is also organized as a node, and the right side is the asset under that node' %} -
    +{#
    #} {% endblock %} {% block custom_head_css_js %} - -{# #} - +{# #} +{# #}