diff --git a/Dockerfile b/Dockerfile index 5e87bb604..769209673 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,10 +22,13 @@ COPY ./requirements/deb_buster_requirements.txt ./requirements/deb_buster_requir RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \ && sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \ && apt update \ + && apt -y install telnet iproute2 redis-tools \ && grep -v '^#' ./requirements/deb_buster_requirements.txt | xargs apt -y install \ && rm -rf /var/lib/apt/lists/* \ && localedef -c -f UTF-8 -i zh_CN zh_CN.UTF-8 \ - && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime + && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ + && sed -i "s@# alias l@alias l@g" ~/.bashrc \ + && echo "set mouse-=a" > ~/.vimrc COPY ./requirements/requirements.txt ./requirements/requirements.txt RUN pip install --upgrade pip==20.2.4 setuptools==49.6.0 wheel==0.34.2 -i ${PIP_MIRROR} \ @@ -37,6 +40,12 @@ COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver RUN mkdir -p /root/.ssh/ \ && echo "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config +RUN mkdir -p /opt/jumpserver/oracle/ +ADD https://f2c-north-rel.oss-cn-qingdao.aliyuncs.com/2.0/north/jumpserver/instantclient-basiclite-linux.x64-21.1.0.0.0.tar /opt/jumpserver/oracle/ +RUN tar xvf /opt/jumpserver/oracle/instantclient-basiclite-linux.x64-21.1.0.0.0.tar -C /opt/jumpserver/oracle/ +RUN sh -c "echo /opt/jumpserver/oracle/instantclient_21_1 > /etc/ld.so.conf.d/oracle-instantclient.conf" +RUN ldconfig + RUN echo > config.yml VOLUME /opt/jumpserver/data VOLUME /opt/jumpserver/logs diff --git a/apps/acls/models/login_asset_acl.py b/apps/acls/models/login_asset_acl.py index d61e7ae23..bf47fa578 100644 --- a/apps/acls/models/login_asset_acl.py +++ b/apps/acls/models/login_asset_acl.py @@ -83,11 +83,11 @@ class LoginAssetACL(BaseACL, OrgModelMixin): @classmethod def create_login_asset_confirm_ticket(cls, user, asset, system_user, assignees, org_id): - from tickets.const import TicketTypeChoices + from tickets.const import TicketType from tickets.models import Ticket data = { 'title': _('Login asset confirm') + ' ({})'.format(user), - 'type': TicketTypeChoices.login_asset_confirm, + 'type': TicketType.login_asset_confirm, 'meta': { 'apply_login_user': str(user), 'apply_login_asset': str(asset), @@ -96,7 +96,7 @@ class LoginAssetACL(BaseACL, OrgModelMixin): 'org_id': org_id, } ticket = Ticket.objects.create(**data) - ticket.assignees.set(assignees) + ticket.create_process_map_and_node(assignees) ticket.open(applicant=user) return ticket diff --git a/apps/applications/api/account.py b/apps/applications/api/account.py index 6c95a73c8..3193467c3 100644 --- a/apps/applications/api/account.py +++ b/apps/applications/api/account.py @@ -2,74 +2,57 @@ # from django_filters import rest_framework as filters -from django.db.models import F, Value, CharField -from django.db.models.functions import Concat -from django.http import Http404 +from django.db.models import F, Q from common.drf.filters import BaseFilterSet -from common.drf.api import JMSModelViewSet -from common.utils import unique -from perms.models import ApplicationPermission +from common.drf.api import JMSBulkModelViewSet +from ..models import Account from ..hands import IsOrgAdminOrAppUser, IsOrgAdmin, NeedMFAVerify from .. import serializers class AccountFilterSet(BaseFilterSet): username = filters.CharFilter(field_name='username') - app = filters.CharFilter(field_name='applications', lookup_expr='exact') - app_name = filters.CharFilter(field_name='app_name', lookup_expr='exact') + type = filters.CharFilter(field_name='type', lookup_expr='exact') + category = filters.CharFilter(field_name='category', lookup_expr='exact') + app_display = filters.CharFilter(field_name='app_display', lookup_expr='exact') class Meta: - model = ApplicationPermission - fields = ['type', 'category'] + model = Account + fields = ['app', 'systemuser'] + + @property + def qs(self): + qs = super().qs + qs = self.filter_username(qs) + return qs + + def filter_username(self, qs): + username = self.get_query_param('username') + if not username: + return qs + qs = qs.filter(Q(username=username) | Q(systemuser__username=username)).distinct() + return qs -class ApplicationAccountViewSet(JMSModelViewSet): - permission_classes = (IsOrgAdmin, ) - search_fields = ['username', 'app_name'] +class ApplicationAccountViewSet(JMSBulkModelViewSet): + model = Account + search_fields = ['username', 'app_display'] filterset_class = AccountFilterSet - filterset_fields = ['username', 'app_name', 'type', 'category'] - serializer_class = serializers.ApplicationAccountSerializer - http_method_names = ['get', 'put', 'patch', 'options'] + filterset_fields = ['username', 'app_display', 'type', 'category', 'app'] + serializer_class = serializers.AppAccountSerializer + permission_classes = (IsOrgAdmin,) def get_queryset(self): - queryset = ApplicationPermission.objects\ - .exclude(system_users__isnull=True) \ - .exclude(applications__isnull=True) \ - .annotate(uid=Concat( - 'applications', Value('_'), 'system_users', output_field=CharField() - )) \ - .annotate(systemuser=F('system_users')) \ - .annotate(systemuser_display=F('system_users__name')) \ - .annotate(username=F('system_users__username')) \ - .annotate(password=F('system_users__password')) \ - .annotate(app=F('applications')) \ - .annotate(app_name=F("applications__name")) \ - .values('username', 'password', 'systemuser', 'systemuser_display', - 'app', 'app_name', 'category', 'type', 'uid', 'org_id') - return queryset - - def get_object(self): - obj = self.get_queryset().filter( - uid=self.kwargs['pk'] - ).first() - if not obj: - raise Http404() - return obj - - def filter_queryset(self, queryset): - queryset = super().filter_queryset(queryset) - queryset_list = unique(queryset, key=lambda x: (x['app'], x['systemuser'])) - return queryset_list - - @staticmethod - def filter_spm_queryset(resource_ids, queryset): - queryset = queryset.filter(uid__in=resource_ids) + queryset = Account.objects.all() \ + .annotate(type=F('app__type')) \ + .annotate(app_display=F('app__name')) \ + .annotate(systemuser_display=F('systemuser__name')) \ + .annotate(category=F('app__category')) return queryset class ApplicationAccountSecretViewSet(ApplicationAccountViewSet): - serializer_class = serializers.ApplicationAccountSecretSerializer + serializer_class = serializers.AppAccountSecretSerializer permission_classes = [IsOrgAdminOrAppUser, NeedMFAVerify] http_method_names = ['get', 'options'] - diff --git a/apps/applications/api/application.py b/apps/applications/api/application.py index 235f0e789..1090e0095 100644 --- a/apps/applications/api/application.py +++ b/apps/applications/api/application.py @@ -6,11 +6,10 @@ from rest_framework.decorators import action from rest_framework.response import Response from common.tree import TreeNodeSerializer -from ..hands import IsOrgAdminOrAppUser +from ..hands import IsOrgAdminOrAppUser, IsValidUser from .. import serializers from ..models import Application - __all__ = ['ApplicationViewSet'] @@ -24,7 +23,7 @@ class ApplicationViewSet(OrgBulkModelViewSet): search_fields = ('name', 'type', 'category') permission_classes = (IsOrgAdminOrAppUser,) serializer_classes = { - 'default': serializers.ApplicationSerializer, + 'default': serializers.AppSerializer, 'get_tree': TreeNodeSerializer } @@ -35,3 +34,9 @@ class ApplicationViewSet(OrgBulkModelViewSet): tree_nodes = Application.create_tree_nodes(queryset, show_count=show_count) serializer = self.get_serializer(tree_nodes, many=True) return Response(serializer.data) + + @action(methods=['get'], detail=False, permission_classes=(IsValidUser,)) + def suggestion(self, request): + queryset = self.filter_queryset(self.get_queryset())[:3] + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) diff --git a/apps/applications/migrations/0010_appaccount_historicalappaccount.py b/apps/applications/migrations/0010_appaccount_historicalappaccount.py new file mode 100644 index 000000000..fc2cf2ab9 --- /dev/null +++ b/apps/applications/migrations/0010_appaccount_historicalappaccount.py @@ -0,0 +1,76 @@ +# Generated by Django 3.1.12 on 2021-08-26 09:07 + +import assets.models.base +import common.fields.model +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import simple_history.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('assets', '0076_delete_assetuser'), + ('applications', '0009_applicationuser'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalAccount', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4)), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('username', models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username')), + ('password', common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')), + ('private_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')), + ('public_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')), + ('comment', models.TextField(blank=True, verbose_name='Comment')), + ('date_created', models.DateTimeField(blank=True, editable=False, verbose_name='Date created')), + ('date_updated', models.DateTimeField(blank=True, editable=False, verbose_name='Date updated')), + ('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')), + ('version', models.IntegerField(default=1, verbose_name='Version')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('app', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='applications.application', verbose_name='Database')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('systemuser', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='assets.systemuser', verbose_name='System user')), + ], + options={ + 'verbose_name': 'historical Account', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='Account', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('username', models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username')), + ('password', common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')), + ('private_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')), + ('public_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')), + ('comment', models.TextField(blank=True, verbose_name='Comment')), + ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')), + ('version', models.IntegerField(default=1, verbose_name='Version')), + ('app', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='applications.application', verbose_name='Database')), + ('systemuser', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='assets.systemuser', verbose_name='System user')), + ], + options={ + 'verbose_name': 'Account', + 'unique_together': {('username', 'app', 'systemuser')}, + }, + bases=(models.Model, assets.models.base.AuthMixin), + ), + ] diff --git a/apps/applications/migrations/0011_auto_20210826_1759.py b/apps/applications/migrations/0011_auto_20210826_1759.py new file mode 100644 index 000000000..937102c07 --- /dev/null +++ b/apps/applications/migrations/0011_auto_20210826_1759.py @@ -0,0 +1,40 @@ +# Generated by Django 3.1.12 on 2021-08-26 09:59 + +from django.db import migrations, transaction +from django.db.models import F + + +def migrate_app_account(apps, schema_editor): + db_alias = schema_editor.connection.alias + app_perm_model = apps.get_model("perms", "ApplicationPermission") + app_account_model = apps.get_model("applications", 'Account') + + queryset = app_perm_model.objects \ + .exclude(system_users__isnull=True) \ + .exclude(applications__isnull=True) \ + .annotate(systemuser=F('system_users')) \ + .annotate(app=F('applications')) \ + .values('app', 'systemuser', 'org_id') + + accounts = [] + for p in queryset: + if not p['app']: + continue + account = app_account_model( + app_id=p['app'], systemuser_id=p['systemuser'], + version=1, org_id=p['org_id'] + ) + accounts.append(account) + + app_account_model.objects.using(db_alias).bulk_create(accounts, ignore_conflicts=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0010_appaccount_historicalappaccount'), + ] + + operations = [ + migrations.RunPython(migrate_app_account) + ] diff --git a/apps/applications/models/__init__.py b/apps/applications/models/__init__.py index a12310aa4..4bd20e32f 100644 --- a/apps/applications/models/__init__.py +++ b/apps/applications/models/__init__.py @@ -1 +1,2 @@ from .application import * +from .account import * diff --git a/apps/applications/models/account.py b/apps/applications/models/account.py index e69de29bb..ff514590a 100644 --- a/apps/applications/models/account.py +++ b/apps/applications/models/account.py @@ -0,0 +1,88 @@ +from django.db import models +from simple_history.models import HistoricalRecords +from django.utils.translation import ugettext_lazy as _ + +from common.utils import lazyproperty +from assets.models.base import BaseUser + + +class Account(BaseUser): + app = models.ForeignKey('applications.Application', on_delete=models.CASCADE, null=True, verbose_name=_('Database')) + systemuser = models.ForeignKey('assets.SystemUser', on_delete=models.CASCADE, null=True, verbose_name=_("System user")) + version = models.IntegerField(default=1, verbose_name=_('Version')) + history = HistoricalRecords() + + auth_attrs = ['username', 'password', 'private_key', 'public_key'] + + class Meta: + verbose_name = _('Account') + unique_together = [('username', 'app', 'systemuser')] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.auth_snapshot = {} + + def get_or_systemuser_attr(self, attr): + val = getattr(self, attr, None) + if val: + return val + if self.systemuser: + return getattr(self.systemuser, attr, '') + return '' + + def load_auth(self): + for attr in self.auth_attrs: + value = self.get_or_systemuser_attr(attr) + self.auth_snapshot[attr] = [getattr(self, attr), value] + setattr(self, attr, value) + + def unload_auth(self): + if not self.systemuser: + return + + for attr, values in self.auth_snapshot.items(): + origin_value, loaded_value = values + current_value = getattr(self, attr, '') + if current_value == loaded_value: + setattr(self, attr, origin_value) + + def save(self, *args, **kwargs): + self.unload_auth() + instance = super().save(*args, **kwargs) + self.load_auth() + return instance + + @lazyproperty + def category(self): + return self.app.category + + @lazyproperty + def type(self): + return self.app.type + + @lazyproperty + def app_display(self): + return self.systemuser.name + + @property + def username_display(self): + return self.get_or_systemuser_attr('username') or '' + + @lazyproperty + def systemuser_display(self): + if not self.systemuser: + return '' + return str(self.systemuser) + + @property + def smart_name(self): + username = self.username_display + + if self.app: + app = str(self.app) + else: + app = '*' + return '{}@{}'.format(username, app) + + def __str__(self): + return self.smart_name diff --git a/apps/applications/serializers/application.py b/apps/applications/serializers/application.py index b88fcef21..79cbdd3bc 100644 --- a/apps/applications/serializers/application.py +++ b/apps/applications/serializers/application.py @@ -4,20 +4,23 @@ from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ -from orgs.models import Organization from orgs.mixins.serializers import BulkOrgResourceModelSerializer +from assets.serializers.base import AuthSerializerMixin from common.drf.serializers import MethodSerializer -from .attrs import category_serializer_classes_mapping, type_serializer_classes_mapping +from .attrs import ( + category_serializer_classes_mapping, + type_serializer_classes_mapping +) from .. import models from .. import const __all__ = [ - 'ApplicationSerializer', 'ApplicationSerializerMixin', - 'ApplicationAccountSerializer', 'ApplicationAccountSecretSerializer' + 'AppSerializer', 'AppSerializerMixin', + 'AppAccountSerializer', 'AppAccountSecretSerializer' ] -class ApplicationSerializerMixin(serializers.Serializer): +class AppSerializerMixin(serializers.Serializer): attrs = MethodSerializer() def get_attrs_serializer(self): @@ -45,8 +48,14 @@ class ApplicationSerializerMixin(serializers.Serializer): serializer = serializer_class return serializer + def create(self, validated_data): + return super().create(validated_data) -class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSerializer): + def update(self, instance, validated_data): + return super().update(instance, validated_data) + + +class AppSerializer(AppSerializerMixin, BulkOrgResourceModelSerializer): category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category display')) type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) @@ -69,42 +78,54 @@ class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSeri return _attrs -class ApplicationAccountSerializer(serializers.Serializer): - id = serializers.ReadOnlyField(label=_("Id"), source='uid') - username = serializers.ReadOnlyField(label=_("Username")) - password = serializers.CharField(write_only=True, label=_("Password")) - systemuser = serializers.ReadOnlyField(label=_('System user')) - systemuser_display = serializers.ReadOnlyField(label=_("System user display")) - app = serializers.ReadOnlyField(label=_('App')) - app_name = serializers.ReadOnlyField(label=_("Application name"), read_only=True) +class AppAccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): category = serializers.ChoiceField(label=_('Category'), choices=const.AppCategory.choices, read_only=True) category_display = serializers.SerializerMethodField(label=_('Category display')) type = serializers.ChoiceField(label=_('Type'), choices=const.AppType.choices, read_only=True) type_display = serializers.SerializerMethodField(label=_('Type display')) - uid = serializers.ReadOnlyField(label=_("Union id")) - org_id = serializers.ReadOnlyField(label=_("Organization")) - org_name = serializers.SerializerMethodField(label=_("Org name")) category_mapper = dict(const.AppCategory.choices) type_mapper = dict(const.AppType.choices) - def create(self, validated_data): - pass - - def update(self, instance, validated_data): - pass + class Meta: + model = models.Account + fields_mini = ['id', 'username', 'version'] + fields_write_only = ['password', 'private_key'] + fields_fk = ['systemuser', 'systemuser_display', 'app', 'app_display'] + fields = fields_mini + fields_fk + fields_write_only + [ + 'type', 'type_display', 'category', 'category_display', + ] + extra_kwargs = { + 'username': {'default': '', 'required': False}, + 'password': {'write_only': True}, + 'app_display': {'label': _('Application display')} + } + use_model_bulk_create = True + model_bulk_create_kwargs = { + 'ignore_conflicts': True + } def get_category_display(self, obj): - return self.category_mapper.get(obj['category']) + return self.category_mapper.get(obj.category) def get_type_display(self, obj): - return self.type_mapper.get(obj['type']) + return self.type_mapper.get(obj.type) - @staticmethod - def get_org_name(obj): - org = Organization.get_instance(obj['org_id']) - return org.name + @classmethod + def setup_eager_loading(cls, queryset): + """ Perform necessary eager loading of data. """ + queryset = queryset.prefetch_related('systemuser', 'app') + return queryset + + def to_representation(self, instance): + instance.load_auth() + return super().to_representation(instance) -class ApplicationAccountSecretSerializer(ApplicationAccountSerializer): - password = serializers.CharField(write_only=False, label=_("Password")) +class AppAccountSecretSerializer(AppAccountSerializer): + class Meta(AppAccountSerializer.Meta): + extra_kwargs = { + 'password': {'write_only': False}, + 'private_key': {'write_only': False}, + 'public_key': {'write_only': False}, + } diff --git a/apps/assets/api/accounts.py b/apps/assets/api/accounts.py index 778916e64..e05bee3d2 100644 --- a/apps/assets/api/accounts.py +++ b/apps/assets/api/accounts.py @@ -64,8 +64,8 @@ class AccountViewSet(OrgBulkModelViewSet): permission_classes = (IsOrgAdmin,) def get_queryset(self): - queryset = super().get_queryset()\ - .annotate(ip=F('asset__ip'))\ + queryset = super().get_queryset() \ + .annotate(ip=F('asset__ip')) \ .annotate(hostname=F('asset__hostname')) return queryset @@ -110,4 +110,5 @@ class AccountTaskCreateAPI(CreateAPIView): def get_exception_handler(self): def handler(e, context): return Response({"error": str(e)}, status=400) + return handler diff --git a/apps/assets/api/asset.py b/apps/assets/api/asset.py index 788b91257..35023c39d 100644 --- a/apps/assets/api/asset.py +++ b/apps/assets/api/asset.py @@ -1,15 +1,17 @@ # -*- coding: utf-8 -*- # from assets.api import FilterAssetByNodeMixin +from rest_framework.decorators import action from rest_framework.viewsets import ModelViewSet from rest_framework.generics import RetrieveAPIView +from rest_framework.response import Response from django.shortcuts import get_object_or_404 from common.utils import get_logger, get_object_or_none -from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser +from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser, IsValidUser from orgs.mixins.api import OrgBulkModelViewSet from orgs.mixins import generics -from ..models import Asset, Node, Platform, SystemUser +from ..models import Asset, Node, Platform from .. import serializers from ..tasks import ( update_assets_hardware_info_manual, test_assets_connectivity_manual, @@ -17,7 +19,6 @@ from ..tasks import ( ) from ..filters import FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend - logger = get_logger(__file__) __all__ = [ 'AssetViewSet', 'AssetPlatformRetrieveApi', @@ -43,6 +44,7 @@ class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet): ordering_fields = ("hostname", "ip", "port", "cpu_cores") serializer_classes = { 'default': serializers.AssetSerializer, + 'suggestion': serializers.MiniAssetSerializer } permission_classes = (IsOrgAdminOrAppUser,) extra_filter_backends = [FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend] @@ -62,6 +64,12 @@ class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet): assets = serializer.save() self.set_assets_node(assets) + @action(methods=['get'], detail=False, permission_classes=(IsValidUser,)) + def suggestion(self, request): + queryset = self.filter_queryset(self.get_queryset())[:3] + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + class AssetPlatformRetrieveApi(RetrieveAPIView): queryset = Platform.objects.all() diff --git a/apps/assets/api/system_user_relation.py b/apps/assets/api/system_user_relation.py index cd7b64a68..374d45cc2 100644 --- a/apps/assets/api/system_user_relation.py +++ b/apps/assets/api/system_user_relation.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # from collections import defaultdict -from django.db.models import F, Value +from django.db.models import F, Value, Model from django.db.models.signals import m2m_changed from django.db.models.functions import Concat @@ -13,13 +13,15 @@ from .. import models, serializers __all__ = [ 'SystemUserAssetRelationViewSet', 'SystemUserNodeRelationViewSet', - 'SystemUserUserRelationViewSet', + 'SystemUserUserRelationViewSet', 'BaseRelationViewSet', ] logger = get_logger(__name__) class RelationMixin: + model: Model + def get_queryset(self): queryset = self.model.objects.all() if not current_org.is_root(): diff --git a/apps/assets/migrations/0071_systemuser_type.py b/apps/assets/migrations/0071_systemuser_type.py index c7a3a88fe..8ca48370a 100644 --- a/apps/assets/migrations/0071_systemuser_type.py +++ b/apps/assets/migrations/0071_systemuser_type.py @@ -1,7 +1,8 @@ # Generated by Django 3.1.6 on 2021-06-04 16:46 - +import uuid from django.db import migrations, models, transaction import django.db.models.deletion +from django.db import IntegrityError from django.db.models import F @@ -15,7 +16,7 @@ def migrate_admin_user_to_system_user(apps, schema_editor): for admin_user in admin_users: kwargs = {} for attr in [ - 'id', 'org_id', 'username', 'password', 'private_key', 'public_key', + 'org_id', 'username', 'password', 'private_key', 'public_key', 'comment', 'date_created', 'date_updated', 'created_by', ]: value = getattr(admin_user, attr) @@ -27,7 +28,16 @@ def migrate_admin_user_to_system_user(apps, schema_editor): ).exists() if exist: name = admin_user.name + '_' + str(admin_user.id)[:5] + + i = admin_user.id + exist = system_user_model.objects.using(db_alias).filter( + id=i, org_id=admin_user.org_id + ).exists() + if exist: + i = uuid.uuid4() + kwargs.update({ + 'id': i, 'name': name, 'type': 'admin', 'protocol': 'ssh', @@ -36,7 +46,11 @@ def migrate_admin_user_to_system_user(apps, schema_editor): with transaction.atomic(): s = system_user_model(**kwargs) - s.save() + try: + s.save() + except IntegrityError: + s.id = None + s.save() print(" Migrate admin user to system user: {} => {}".format(admin_user.name, s.name)) assets = admin_user.assets.all() s.assets.set(assets) diff --git a/apps/assets/models/authbook.py b/apps/assets/models/authbook.py index 3153608cd..c4e39bcd4 100644 --- a/apps/assets/models/authbook.py +++ b/apps/assets/models/authbook.py @@ -16,7 +16,6 @@ class AuthBook(BaseUser, AbsConnectivity): systemuser = models.ForeignKey('assets.SystemUser', on_delete=models.CASCADE, null=True, verbose_name=_("System user")) version = models.IntegerField(default=1, verbose_name=_('Version')) history = HistoricalRecords() - _systemuser_display = '' auth_attrs = ['username', 'password', 'private_key', 'public_key'] @@ -64,8 +63,6 @@ class AuthBook(BaseUser, AbsConnectivity): @lazyproperty def systemuser_display(self): - if self._systemuser_display: - return self._systemuser_display if not self.systemuser: return '' return str(self.systemuser) diff --git a/apps/assets/models/cmd_filter.py b/apps/assets/models/cmd_filter.py index 1ef14bad0..bf91a16b2 100644 --- a/apps/assets/models/cmd_filter.py +++ b/apps/assets/models/cmd_filter.py @@ -105,11 +105,11 @@ class CommandFilterRule(OrgModelMixin): return '{} % {}'.format(self.type, self.content) def create_command_confirm_ticket(self, run_command, session, cmd_filter_rule, org_id): - from tickets.const import TicketTypeChoices + from tickets.const import TicketType from tickets.models import Ticket data = { 'title': _('Command confirm') + ' ({})'.format(session.user), - 'type': TicketTypeChoices.command_confirm, + 'type': TicketType.command_confirm, 'meta': { 'apply_run_user': session.user, 'apply_run_asset': session.asset, @@ -122,6 +122,6 @@ class CommandFilterRule(OrgModelMixin): 'org_id': org_id, } ticket = Ticket.objects.create(**data) - ticket.assignees.set(self.reviewers.all()) + ticket.create_process_map_and_node(self.reviewers.all()) ticket.open(applicant=session.user_obj) return ticket diff --git a/apps/assets/models/user.py b/apps/assets/models/user.py index ee802e311..3677144c2 100644 --- a/apps/assets/models/user.py +++ b/apps/assets/models/user.py @@ -73,6 +73,10 @@ class ProtocolMixin: def can_perm_to_asset(self): return self.protocol in self.ASSET_CATEGORY_PROTOCOLS + @property + def is_asset_protocol(self): + return self.protocol in self.ASSET_CATEGORY_PROTOCOLS + class AuthMixin: username_same_with_user: bool diff --git a/apps/assets/serializers/asset.py b/apps/assets/serializers/asset.py index f387df071..8266c69c2 100644 --- a/apps/assets/serializers/asset.py +++ b/apps/assets/serializers/asset.py @@ -8,7 +8,7 @@ from orgs.mixins.serializers import BulkOrgResourceModelSerializer from ..models import Asset, Node, Platform, SystemUser __all__ = [ - 'AssetSerializer', 'AssetSimpleSerializer', + 'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer', 'ProtocolsField', 'PlatformSerializer', 'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField' ] @@ -69,6 +69,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer): """ 资产的数据结构 """ + class Meta: model = Asset fields_mini = ['id', 'hostname', 'ip', 'platform', 'protocols'] @@ -157,6 +158,12 @@ class AssetSerializer(BulkOrgResourceModelSerializer): return instance +class MiniAssetSerializer(serializers.ModelSerializer): + class Meta: + model = Asset + fields = AssetSerializer.Meta.fields_mini + + class PlatformSerializer(serializers.ModelSerializer): meta = serializers.DictField(required=False, allow_null=True, label=_('Meta')) @@ -177,7 +184,6 @@ class PlatformSerializer(serializers.ModelSerializer): class AssetSimpleSerializer(serializers.ModelSerializer): - class Meta: model = Asset fields = ['id', 'hostname', 'ip', 'port', 'connectivity', 'date_verified'] diff --git a/apps/assets/serializers/system_user.py b/apps/assets/serializers/system_user.py index c5b9c2064..69848ce20 100644 --- a/apps/assets/serializers/system_user.py +++ b/apps/assets/serializers/system_user.py @@ -14,7 +14,7 @@ __all__ = [ 'SystemUserSimpleSerializer', 'SystemUserAssetRelationSerializer', 'SystemUserNodeRelationSerializer', 'SystemUserTaskSerializer', 'SystemUserUserRelationSerializer', 'SystemUserWithAuthInfoSerializer', - 'SystemUserTempAuthSerializer', + 'SystemUserTempAuthSerializer', 'RelationMixin', ] @@ -31,12 +31,12 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): fields_mini = ['id', 'name', 'username'] fields_write_only = ['password', 'public_key', 'private_key'] fields_small = fields_mini + fields_write_only + [ - 'type', 'type_display', 'protocol', 'login_mode', 'login_mode_display', - 'priority', 'sudo', 'shell', 'sftp_root', 'token', 'ssh_key_fingerprint', - 'home', 'system_groups', 'ad_domain', + 'token', 'ssh_key_fingerprint', + 'type', 'type_display', 'protocol', 'is_asset_protocol', + 'login_mode', 'login_mode_display', 'priority', + 'sudo', 'shell', 'sftp_root', 'home', 'system_groups', 'ad_domain', 'username_same_with_user', 'auto_push', 'auto_generate_key', - 'date_created', 'date_updated', - 'comment', 'created_by', + 'date_created', 'date_updated', 'comment', 'created_by', ] fields_m2m = ['cmd_filters', 'assets_amount'] fields = fields_small + fields_m2m @@ -53,6 +53,7 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): 'login_mode_display': {'label': _('Login mode display')}, 'created_by': {'read_only': True}, 'ad_domain': {'required': False, 'allow_blank': True, 'label': _('Ad domain')}, + 'is_asset_protocol': {'label': _('Is asset protocol')} } def validate_auto_push(self, value): diff --git a/apps/assets/signals_handler/system_user.py b/apps/assets/signals_handler/system_user.py index 9e1ee2045..00111030c 100644 --- a/apps/assets/signals_handler/system_user.py +++ b/apps/assets/signals_handler/system_user.py @@ -131,8 +131,8 @@ def on_system_user_groups_change(instance, action, pk_set, reverse, **kwargs): @on_transaction_commit def on_system_user_update(instance: SystemUser, created, **kwargs): """ - 当系统用户更新时,可能更新了秘钥,用户名等,这时要自动推送系统用户到资产上, - 其实应该当 用户名,密码,秘钥 sudo等更新时再推送,这里偷个懒, + 当系统用户更新时,可能更新了密钥,用户名等,这时要自动推送系统用户到资产上, + 其实应该当 用户名,密码,密钥 sudo等更新时再推送,这里偷个懒, 这里直接取了 instance.assets 因为nodes和系统用户发生变化时,会自动将nodes下的资产 关联到上面 """ diff --git a/apps/audits/tasks.py b/apps/audits/tasks.py index 467967b82..171fbe633 100644 --- a/apps/audits/tasks.py +++ b/apps/audits/tasks.py @@ -5,14 +5,12 @@ from django.utils import timezone from celery import shared_task from ops.celery.decorator import ( - register_as_period_task, after_app_shutdown_clean_periodic + register_as_period_task ) from .models import UserLoginLog, OperateLog from common.utils import get_log_keep_day -@shared_task -@after_app_shutdown_clean_periodic def clean_login_log_period(): now = timezone.now() days = get_log_keep_day('LOGIN_LOG_KEEP_DAYS') @@ -20,8 +18,6 @@ def clean_login_log_period(): UserLoginLog.objects.filter(datetime__lt=expired_day).delete() -@shared_task -@after_app_shutdown_clean_periodic def clean_operation_log_period(): now = timezone.now() days = get_log_keep_day('OPERATE_LOG_KEEP_DAYS') @@ -29,7 +25,6 @@ def clean_operation_log_period(): OperateLog.objects.filter(datetime__lt=expired_day).delete() -@shared_task def clean_ftp_log_period(): now = timezone.now() days = get_log_keep_day('FTP_LOG_KEEP_DAYS') diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index 383a87c32..f13254b45 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -74,6 +74,7 @@ class ClientProtocolMixin: 'bookmarktype:i': '3', 'use redirection server name:i': '0', 'smart sizing:i': '0', + #'drivestoredirect:s': '*', # 'domain:s': '' # 'alternate shell:s:': '||MySQLWorkbench', # 'remoteapplicationname:s': 'Firefox', @@ -84,8 +85,11 @@ class ClientProtocolMixin: height = self.request.query_params.get('height') width = self.request.query_params.get('width') full_screen = is_true(self.request.query_params.get('full_screen')) + drives_redirect = is_true(self.request.query_params.get('drives_redirect')) token = self.create_token(user, asset, application, system_user) + if drives_redirect: + options['drivestoredirect:s'] = '*' options['screen mode id:i'] = '2' if full_screen else '1' address = settings.TERMINAL_RDP_ADDR if not address or address == 'localhost:3389': diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py index 527e473d8..93b33c4f9 100644 --- a/apps/authentication/api/login_confirm.py +++ b/apps/authentication/api/login_confirm.py @@ -45,5 +45,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView): ticket = self.get_ticket() if ticket: request.session.pop('auth_ticket_id', '') - ticket.close(processor=request.user) + ticket.close(processor=self.get_user_from_session()) return Response('', status=200) diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index b81eeee29..f067d5c5c 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # +import builtins import time from django.utils.translation import ugettext as _ from django.conf import settings @@ -8,14 +9,28 @@ from rest_framework.generics import CreateAPIView from rest_framework.serializers import ValidationError from rest_framework.response import Response -from common.permissions import IsValidUser, NeedMFAVerify +from authentication.sms_verify_code import VerifyCodeUtil +from common.exceptions import JMSException +from common.permissions import IsValidUser, NeedMFAVerify, IsAppUser +from users.models.user import MFAType from ..serializers import OtpVerifySerializer from .. import serializers from .. import errors from ..mixins import AuthMixin -__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi'] +__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi', 'SendSMSVerifyCodeApi', 'MFASelectTypeApi'] + + +class MFASelectTypeApi(AuthMixin, CreateAPIView): + permission_classes = (AllowAny,) + serializer_class = serializers.MFASelectTypeSerializer + + def perform_create(self, serializer): + mfa_type = serializer.validated_data['type'] + if mfa_type == MFAType.SMS_CODE: + user = self.get_user_from_session() + user.send_sms_code() class MFAChallengeApi(AuthMixin, CreateAPIView): @@ -26,7 +41,9 @@ class MFAChallengeApi(AuthMixin, CreateAPIView): try: user = self.get_user_from_session() code = serializer.validated_data.get('code') - valid = user.check_mfa(code) + mfa_type = serializer.validated_data.get('type', MFAType.OTP) + + valid = user.check_mfa(code, mfa_type=mfa_type) if not valid: self.request.session['auth_mfa'] = '' raise errors.MFAFailedError( @@ -67,3 +84,12 @@ class UserOtpVerifyApi(CreateAPIView): if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA: self.permission_classes = [NeedMFAVerify] return super().get_permissions() + + +class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView): + permission_classes = (AllowAny,) + + def create(self, request, *args, **kwargs): + user = self.get_user_from_session() + timeout = user.send_sms_code() + return Response({'code': 'ok','timeout': timeout}) diff --git a/apps/authentication/backends/cas/__init__.py b/apps/authentication/backends/cas/__init__.py index bf0101c81..bbdbdb814 100644 --- a/apps/authentication/backends/cas/__init__.py +++ b/apps/authentication/backends/cas/__init__.py @@ -1,4 +1,3 @@ # -*- coding: utf-8 -*- # from .backends import * -from .callback import * diff --git a/apps/authentication/backends/cas/callback.py b/apps/authentication/backends/cas/callback.py deleted file mode 100644 index 64201e607..000000000 --- a/apps/authentication/backends/cas/callback.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# -from django.contrib.auth import get_user_model - - -User = get_user_model() - - -def cas_callback(response): - username = response['username'] - user, user_created = User.objects.get_or_create(username=username) - profile, created = user.get_profile() - - profile.role = response['attributes']['role'] - profile.birth_date = response['attributes']['birth_date'] - profile.save() diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index ad8148182..c8005ba95 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -4,9 +4,11 @@ from django.utils.translation import ugettext_lazy as _ from django.urls import reverse from django.conf import settings +from authentication import sms_verify_code from common.exceptions import JMSException from .signals import post_auth_failed from users.utils import LoginBlockUtil, MFABlockUtils +from users.models import MFAType reason_password_failed = 'password_failed' reason_password_decrypt_failed = 'password_decrypt_failed' @@ -58,8 +60,18 @@ block_mfa_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, " +otp_failed_msg = _( + "One-time password invalid, or ntp sync server time, " + "You can also try {times_try} times " + "(The account will be temporarily locked for {block_time} minutes)" +) +sms_failed_msg = _( + "SMS verify code invalid," + "You can also try {times_try} times " + "(The account will be temporarily locked for {block_time} minutes)" +) +mfa_type_failed_msg = _( + "The MFA type({mfa_type}) is not supported" "You can also try {times_try} times " "(The account will be temporarily locked for {block_time} minutes)" ) @@ -134,7 +146,7 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError): error = reason_mfa_failed msg: str - def __init__(self, username, request, ip): + def __init__(self, username, request, ip, mfa_type=MFAType.OTP): util = MFABlockUtils(username, ip) util.incr_failed_count() @@ -142,9 +154,18 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError): block_time = settings.SECURITY_LOGIN_LIMIT_TIME if times_remainder: - self.msg = mfa_failed_msg.format( - times_try=times_remainder, block_time=block_time - ) + if mfa_type == MFAType.OTP: + self.msg = otp_failed_msg.format( + times_try=times_remainder, block_time=block_time + ) + elif mfa_type == MFAType.SMS_CODE: + self.msg = sms_failed_msg.format( + times_try=times_remainder, block_time=block_time + ) + else: + self.msg = mfa_type_failed_msg.format( + mfa_type=mfa_type, times_try=times_remainder, block_time=block_time + ) else: self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) super().__init__(username=username, request=request) @@ -202,12 +223,16 @@ class MFARequiredError(NeedMoreInfoError): msg = mfa_required_msg error = 'mfa_required' + def __init__(self, error='', msg='', mfa_types=tuple(MFAType)): + super().__init__(error=error, msg=msg) + self.choices = mfa_types + def as_data(self): return { 'error': self.error, 'msg': self.msg, 'data': { - 'choices': ['code'], + 'choices': self.choices, 'url': reverse('api-auth:mfa-challenge') } } diff --git a/apps/authentication/forms.py b/apps/authentication/forms.py index a4b07700c..948ceabff 100644 --- a/apps/authentication/forms.py +++ b/apps/authentication/forms.py @@ -43,7 +43,8 @@ class UserLoginForm(forms.Form): class UserCheckOtpCodeForm(forms.Form): - otp_code = forms.CharField(label=_('MFA code'), max_length=6) + code = forms.CharField(label=_('MFA Code'), max_length=6) + mfa_type = forms.CharField(label=_('MFA type'), max_length=6) class CustomCaptchaTextInput(CaptchaTextInput): diff --git a/apps/authentication/middleware.py b/apps/authentication/middleware.py new file mode 100644 index 000000000..59eabff75 --- /dev/null +++ b/apps/authentication/middleware.py @@ -0,0 +1,14 @@ +from django.shortcuts import redirect + + +class MFAMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + if request.path.find('/auth/login/otp/') > -1: + return response + if request.session.get('auth_mfa_required'): + return redirect('authentication:login-otp') + return response diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 1eb39fee1..3a2f9c09b 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -14,14 +14,15 @@ from django.contrib.auth import ( PermissionDenied, user_login_failed, _clean_credentials ) from django.shortcuts import reverse, redirect +from django.views.generic.edit import FormView from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil -from users.models import User +from users.models import User, MFAType from users.utils import LoginBlockUtil, MFABlockUtils from . import errors -from .utils import rsa_decrypt +from .utils import rsa_decrypt, gen_key_pair from .signals import post_auth_success, post_auth_failed -from .const import RSA_PRIVATE_KEY +from .const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY logger = get_logger(__name__) @@ -79,7 +80,70 @@ def authenticate(request=None, **credentials): auth.authenticate = authenticate -class AuthMixin: +class PasswordEncryptionViewMixin: + request = None + + def get_decrypted_password(self, password=None, username=None): + request = self.request + if hasattr(request, 'data'): + data = request.data + else: + data = request.POST + + username = username or data.get('username') + password = password or data.get('password') + + password = self.decrypt_passwd(password) + if not password: + self.raise_password_decrypt_failed(username=username) + return password + + def raise_password_decrypt_failed(self, username): + ip = self.get_request_ip() + raise errors.CredentialError( + error=errors.reason_password_decrypt_failed, + username=username, ip=ip, request=self.request + ) + + def decrypt_passwd(self, raw_passwd): + # 获取解密密钥,对密码进行解密 + rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) + if rsa_private_key is not None: + try: + return rsa_decrypt(raw_passwd, rsa_private_key) + except Exception as e: + logger.error(e, exc_info=True) + logger.error( + f'Decrypt password failed: password[{raw_passwd}] ' + f'rsa_private_key[{rsa_private_key}]' + ) + return None + return raw_passwd + + 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 get_context_data(self, **kwargs): + # 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用 + rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY) + rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) + if not all((rsa_private_key, rsa_public_key)): + rsa_private_key, rsa_public_key = gen_key_pair() + rsa_public_key = rsa_public_key.replace('\n', '\\n') + self.request.session[RSA_PRIVATE_KEY] = rsa_private_key + self.request.session[RSA_PUBLIC_KEY] = rsa_public_key + + kwargs.update({ + 'rsa_public_key': rsa_public_key, + }) + return super().get_context_data(**kwargs) + + +class AuthMixin(PasswordEncryptionViewMixin): request = None partial_credential_error = None @@ -106,13 +170,6 @@ class AuthMixin: user.backend = self.request.session.get("auth_backend") 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, username, raise_exception=True): ip = self.get_request_ip() if LoginBlockUtil(username, ip).is_block(): @@ -130,19 +187,6 @@ class AuthMixin: username = self.request.POST.get("username") self._check_is_block(username, raise_exception) - def decrypt_passwd(self, raw_passwd): - # 获取解密密钥,对密码进行解密 - rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) - if rsa_private_key is not None: - try: - return rsa_decrypt(raw_passwd, rsa_private_key) - except Exception as e: - logger.error(e, exc_info=True) - logger.error(f'Decrypt password failed: password[{raw_passwd}] ' - f'rsa_private_key[{rsa_private_key}]') - return None - return raw_passwd - def raise_credential_error(self, error): raise self.partial_credential_error(error=error) @@ -158,14 +202,12 @@ class AuthMixin: items = ['username', 'password', 'challenge', 'public_key', 'auto_login'] username, password, challenge, public_key, auto_login = bulk_get(data, *items, default='') - password = password + challenge.strip() ip = self.get_request_ip() self._set_partial_credential_error(username=username, ip=ip, request=request) + password = password + challenge.strip() if decrypt_passwd: - password = self.decrypt_passwd(password) - if not password: - self.raise_credential_error(errors.reason_password_decrypt_failed) + password = self.get_decrypted_password() return username, password, public_key, ip, auto_login def _check_only_allow_exists_user_auth(self, username): @@ -309,12 +351,13 @@ class AuthMixin: unset, url = user.mfa_enabled_but_not_set() if unset: raise errors.MFAUnsetError(user, self.request, url) - raise errors.MFARequiredError() + raise errors.MFARequiredError(mfa_types=user.get_supported_mfa_types()) - def mark_mfa_ok(self): + def mark_mfa_ok(self, mfa_type=MFAType.OTP): self.request.session['auth_mfa'] = 1 self.request.session['auth_mfa_time'] = time.time() - self.request.session['auth_mfa_type'] = 'otp' + self.request.session['auth_mfa_required'] = '' + self.request.session['auth_mfa_type'] = mfa_type def check_mfa_is_block(self, username, ip, raise_exception=True): if MFABlockUtils(username, ip).is_block(): @@ -325,11 +368,11 @@ class AuthMixin: else: return exception - def check_user_mfa(self, code): + def check_user_mfa(self, code, mfa_type=MFAType.OTP): user = self.get_user_from_session() ip = self.get_request_ip() self.check_mfa_is_block(user.username, ip) - ok = user.check_mfa(code) + ok = user.check_mfa(code, mfa_type=mfa_type) if ok: self.mark_mfa_ok() return @@ -337,7 +380,7 @@ class AuthMixin: raise errors.MFAFailedError( username=user.username, request=self.request, - ip=ip + ip=ip, mfa_type=mfa_type, ) def get_ticket(self): @@ -363,14 +406,14 @@ class AuthMixin: raise errors.LoginConfirmOtherError('', "Not found") if ticket.status_open: raise errors.LoginConfirmWaitError(ticket.id) - elif ticket.action_approve: + elif ticket.state_approve: self.request.session["auth_confirm"] = "1" return - elif ticket.action_reject: + elif ticket.state_reject: raise errors.LoginConfirmOtherError( ticket.id, ticket.get_action_display() ) - elif ticket.action_close: + elif ticket.state_close: raise errors.LoginConfirmOtherError( ticket.id, ticket.get_action_display() ) @@ -391,7 +434,6 @@ class AuthMixin: 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/models.py b/apps/authentication/models.py index f4db736af..c74d06953 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -71,15 +71,14 @@ class LoginConfirmSetting(CommonModelMixin): from orgs.models import Organization ticket_title = _('Login confirm') + ' {}'.format(self.user) ticket_meta = self.construct_confirm_ticket_meta(request) - ticket_assignees = self.reviewers.all() data = { 'title': ticket_title, - 'type': const.TicketTypeChoices.login_confirm.value, + 'type': const.TicketType.login_confirm.value, 'meta': ticket_meta, 'org_id': Organization.ROOT_ID, } ticket = Ticket.objects.create(**data) - ticket.assignees.set(ticket_assignees) + ticket.create_process_map_and_node(self.reviewers.all()) ticket.open(self.user) return ticket diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index 989d94d62..b571dea01 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -16,8 +16,8 @@ from .models import AccessKey, LoginConfirmSetting __all__ = [ 'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer', 'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer', - 'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', 'RDPFileSerializer', - 'PasswordVerifySerializer', + 'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', + 'PasswordVerifySerializer', 'MFASelectTypeSerializer', ] @@ -77,6 +77,10 @@ class BearerTokenSerializer(serializers.Serializer): return instance +class MFASelectTypeSerializer(serializers.Serializer): + type = serializers.CharField() + + class MFAChallengeSerializer(serializers.Serializer): type = serializers.CharField(write_only=True, required=False, allow_blank=True) code = serializers.CharField(write_only=True) @@ -166,7 +170,7 @@ class ConnectionTokenAssetSerializer(serializers.ModelSerializer): class ConnectionTokenSystemUserSerializer(serializers.ModelSerializer): class Meta: model = SystemUser - fields = ['id', 'name', 'username', 'password', 'private_key'] + fields = ['id', 'name', 'username', 'password', 'private_key', 'ad_domain'] class ConnectionTokenGatewaySerializer(serializers.ModelSerializer): diff --git a/apps/authentication/signals_handlers.py b/apps/authentication/signals_handlers.py index 8e353ddf6..c6c1db680 100644 --- a/apps/authentication/signals_handlers.py +++ b/apps/authentication/signals_handlers.py @@ -13,6 +13,10 @@ from .signals import post_auth_success, post_auth_failed @receiver(user_logged_in) def on_user_auth_login_success(sender, user, request, **kwargs): + # 开启了 MFA,且没有校验过 + if user.mfa_enabled and not request.session.get('auth_mfa'): + request.session['auth_mfa_required'] = 1 + if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED: user_id = 'single_machine_login_' + str(user.id) session_key = cache.get(user_id) diff --git a/apps/authentication/sms_verify_code.py b/apps/authentication/sms_verify_code.py new file mode 100644 index 000000000..33d17b207 --- /dev/null +++ b/apps/authentication/sms_verify_code.py @@ -0,0 +1,97 @@ +import random + +from django.conf import settings +from django.core.cache import cache +from django.utils.translation import gettext_lazy as _ + +from common.message.backends.sms.alibaba import AlibabaSMS +from common.message.backends.sms import SMS +from common.utils import get_logger +from common.exceptions import JMSException + +logger = get_logger(__file__) + + +class CodeExpired(JMSException): + default_code = 'verify_code_expired' + default_detail = _('The verification code has expired. Please resend it') + + +class CodeError(JMSException): + default_code = 'verify_code_error' + default_detail = _('The verification code is incorrect') + + +class CodeSendTooFrequently(JMSException): + default_code = 'code_send_too_frequently' + default_detail = _('Please wait {} seconds before sending') + + def __init__(self, ttl): + super().__init__(detail=self.default_detail.format(ttl)) + + +class VerifyCodeUtil: + KEY_TMPL = 'auth-verify_code-{}' + TIMEOUT = 60 + + def __init__(self, account, key_suffix=None, timeout=None): + self.account = account + self.key_suffix = key_suffix + self.code = '' + + if key_suffix is not None: + self.key = self.KEY_TMPL.format(key_suffix) + else: + self.key = self.KEY_TMPL.format(account) + self.timeout = self.TIMEOUT if timeout is None else timeout + + def touch(self): + """ + 生成,保存,发送 + """ + ttl = self.ttl() + if ttl > 0: + raise CodeSendTooFrequently(ttl) + + self.generate() + self.save() + self.send() + + def generate(self): + code = ''.join(random.sample('0123456789', 4)) + self.code = code + return code + + def clear(self): + cache.delete(self.key) + + def save(self): + cache.set(self.key, self.code, self.timeout) + + def send(self): + """ + 发送信息的方法,如果有错误直接抛出 api 异常 + """ + account = self.account + code = self.code + + sms = SMS() + sms.send_verify_code(account, code) + logger.info(f'Send sms verify code: account={account} code={code}') + + def verify(self, code): + right = cache.get(self.key) + if not right: + raise CodeExpired + + if right != code: + raise CodeError + + self.clear() + return True + + def ttl(self): + return cache.ttl(self.key) + + def get_code(self): + return cache.get(self.key) diff --git a/apps/authentication/templates/authentication/login_otp.html b/apps/authentication/templates/authentication/login_otp.html index f17451949..858c2737d 100644 --- a/apps/authentication/templates/authentication/login_otp.html +++ b/apps/authentication/templates/authentication/login_otp.html @@ -9,24 +9,60 @@ {% block content %}
{% csrf_token %} - {% if 'otp_code' in form.errors %} -

{{ form.otp_code.errors.as_text }}

+ {% if 'code' in form.errors %} +

{{ form.code.errors.as_text }}

{% endif %}
- + {% for method in methods %} + + {% endfor %}
- + - {% trans 'Open MFA Authenticator and enter the 6-bit dynamic code' %} + {% trans 'Please enter the verification code' %}
+
{% trans "Can't provide security? Please contact the administrator!" %}
+ {% endblock %} diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index d8613adf4..2dea0da7b 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -27,7 +27,9 @@ urlpatterns = [ 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('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'), path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'), + path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'), path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'), path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'), path('login-confirm-settings//', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update') diff --git a/apps/authentication/utils.py b/apps/authentication/utils.py index f8571d6d7..594e4e68a 100644 --- a/apps/authentication/utils.py +++ b/apps/authentication/utils.py @@ -4,7 +4,6 @@ import base64 from Cryptodome.PublicKey import RSA from Cryptodome.Cipher import PKCS1_v1_5 from Cryptodome import Random - from common.utils import get_logger logger = get_logger(__file__) diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 3fc62e08d..4fc39b4ac 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -51,7 +51,8 @@ class UserLoginView(mixins.AuthMixin, FormView): if settings.AUTH_OPENID: auth_type = 'OIDC' - openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) + f'?next={next_url}' + openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) + openid_auth_url = openid_auth_url + f'?next={next_url}' else: openid_auth_url = None @@ -64,16 +65,13 @@ class UserLoginView(mixins.AuthMixin, FormView): if not any([openid_auth_url, cas_auth_url]): return None - if settings.LOGIN_REDIRECT_TO_BACKEND == 'OPENID' and openid_auth_url: + login_redirect = settings.LOGIN_REDIRECT_TO_BACKEND.lower() + if login_redirect == ['CAS', 'cas'] and cas_auth_url: + auth_url = cas_auth_url + else: auth_url = openid_auth_url - elif settings.LOGIN_REDIRECT_TO_BACKEND == 'CAS' and cas_auth_url: - auth_url = cas_auth_url - - else: - auth_url = openid_auth_url or cas_auth_url - - if settings.LOGIN_REDIRECT_TO_BACKEND: + if settings.LOGIN_REDIRECT_TO_BACKEND or not settings.LOGIN_REDIRECT_MSG_ENABLED: redirect_url = auth_url else: message_data = { @@ -137,15 +135,6 @@ class UserLoginView(mixins.AuthMixin, FormView): self.request.session[RSA_PUBLIC_KEY] = None def get_context_data(self, **kwargs): - # 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用 - rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) - rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY) - if not all((rsa_private_key, rsa_public_key)): - rsa_private_key, rsa_public_key = utils.gen_key_pair() - rsa_public_key = rsa_public_key.replace('\n', '\\n') - self.request.session[RSA_PRIVATE_KEY] = rsa_private_key - self.request.session[RSA_PUBLIC_KEY] = rsa_public_key - forgot_password_url = reverse('authentication:forgot-password') has_other_auth_backend = settings.AUTHENTICATION_BACKENDS[0] != settings.AUTH_BACKEND_MODEL if has_other_auth_backend and settings.FORGOT_PASSWORD_URL: @@ -158,7 +147,6 @@ class UserLoginView(mixins.AuthMixin, FormView): 'AUTH_WECOM': settings.AUTH_WECOM, 'AUTH_DINGTALK': settings.AUTH_DINGTALK, 'AUTH_FEISHU': settings.AUTH_FEISHU, - 'rsa_public_key': rsa_public_key, 'forgot_password_url': forgot_password_url } kwargs.update(context) diff --git a/apps/authentication/views/mfa.py b/apps/authentication/views/mfa.py index f3c2602cb..35fa58f94 100644 --- a/apps/authentication/views/mfa.py +++ b/apps/authentication/views/mfa.py @@ -3,6 +3,8 @@ from __future__ import unicode_literals from django.views.generic.edit import FormView +from django.utils.translation import gettext_lazy as _ +from django.conf import settings from .. import forms, errors, mixins from .utils import redirect_to_guard_view @@ -18,12 +20,14 @@ class UserLoginOtpView(mixins.AuthMixin, FormView): redirect_field_name = 'next' def form_valid(self, form): - otp_code = form.cleaned_data.get('otp_code') + otp_code = form.cleaned_data.get('code') + mfa_type = form.cleaned_data.get('mfa_type') + try: - self.check_user_mfa(otp_code) + self.check_user_mfa(otp_code, mfa_type) return redirect_to_guard_view() except (errors.MFAFailedError, errors.BlockMFAError) as e: - form.add_error('otp_code', e.msg) + form.add_error('code', e.msg) return super().form_invalid(form) except Exception as e: logger.error(e) @@ -31,3 +35,28 @@ class UserLoginOtpView(mixins.AuthMixin, FormView): traceback.print_exception() return redirect_to_guard_view() + def get_context_data(self, **kwargs): + user = self.get_user_from_session() + context = { + 'methods': [ + { + 'name': 'otp', + 'label': _('One-time password'), + 'enable': bool(user.otp_secret_key), + 'selected': False, + }, + { + 'name': 'sms', + 'label': _('SMS'), + 'enable': bool(user.phone) and settings.SMS_ENABLED and settings.XPACK_ENABLED, + 'selected': False, + }, + ] + } + + for item in context['methods']: + if item['enable']: + item['selected'] = True + break + context.update(kwargs) + return context diff --git a/apps/common/db/encoder.py b/apps/common/db/encoder.py new file mode 100644 index 000000000..314ea071d --- /dev/null +++ b/apps/common/db/encoder.py @@ -0,0 +1,20 @@ +import json +from datetime import datetime +import uuid + +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + + +class ModelJSONFieldEncoder(json.JSONEncoder): + """ 解决一些类型的字段不能序列化的问题 """ + + def default(self, obj): + if isinstance(obj, datetime): + return obj.strftime(settings.DATETIME_DISPLAY_FORMAT) + if isinstance(obj, uuid.UUID): + return str(obj) + if isinstance(obj, type(_("ugettext_lazy"))): + return str(obj) + else: + return super().default(obj) diff --git a/apps/common/management/commands/services/services/base.py b/apps/common/management/commands/services/services/base.py index 5063fb92e..0fb6cb1b3 100644 --- a/apps/common/management/commands/services/services/base.py +++ b/apps/common/management/commands/services/services/base.py @@ -160,7 +160,7 @@ class BaseService(object): if self.process: try: self.process.wait(1) # 不wait,子进程可能无法回收 - except subprocess.TimeoutExpired: + except: pass if self.is_running: diff --git a/apps/common/message/backends/dingtalk/__init__.py b/apps/common/message/backends/dingtalk/__init__.py index e98bdee04..1c8a5b59c 100644 --- a/apps/common/message/backends/dingtalk/__init__.py +++ b/apps/common/message/backends/dingtalk/__init__.py @@ -2,9 +2,12 @@ import time import hmac import base64 +from common.utils import get_logger from common.message.backends.utils import digest, as_request from common.message.backends.mixin import BaseRequest +logger = get_logger(__file__) + def sign(secret, data): @@ -160,6 +163,7 @@ class DingTalk: } } } + logger.info(f'Dingtalk send text: user_ids={user_ids} msg={msg}') data = self._request.post(URL.SEND_MESSAGE, json=body, with_token=True) return data diff --git a/apps/common/message/backends/feishu/__init__.py b/apps/common/message/backends/feishu/__init__.py index 7f70fd35d..3bc67b1b5 100644 --- a/apps/common/message/backends/feishu/__init__.py +++ b/apps/common/message/backends/feishu/__init__.py @@ -106,6 +106,7 @@ class FeiShu(RequestMixin): body['receive_id'] = user_id try: + logger.info(f'Feishu send text: user_ids={user_ids} msg={msg}') self._requests.post(URL.SEND_MESSAGE, params=params, json=body) except APIException as e: # 只处理可预知的错误 diff --git a/apps/common/message/backends/sms/__init__.py b/apps/common/message/backends/sms/__init__.py new file mode 100644 index 000000000..be32c6e94 --- /dev/null +++ b/apps/common/message/backends/sms/__init__.py @@ -0,0 +1,84 @@ +from collections import OrderedDict +import importlib + +from django.utils.translation import gettext_lazy as _ +from django.db.models import TextChoices +from django.conf import settings + +from common.utils import get_logger +from common.exceptions import JMSException + +logger = get_logger(__file__) + + +class SMS_MESSAGE(TextChoices): + """ + 定义短信的各种消息类型,会存到类似 `ALIBABA_SMS_SIGN_AND_TEMPLATES` settings 里 + + { + 'verification_code': {'sign_name': 'Jumpserver', 'template_code': 'SMS_222870834'}, + ... + } + """ + + """ + 验证码签名和模板。模板例子: + `您的验证码:${code},您正进行身份验证,打死不告诉别人!` + 其中必须包含 `code` 变量 + """ + VERIFICATION_CODE = 'verification_code' + + def get_sign_and_tmpl(self, config: dict): + try: + data = config[self] + return data['sign_name'], data['template_code'] + except KeyError as e: + raise JMSException( + code=f'{settings.SMS_BACKEND}_sign_and_tmpl_bad', + detail=_('Invalid SMS sign and template: {}').format(e) + ) + + +class BACKENDS(TextChoices): + ALIBABA = 'alibaba', _('Alibaba') + TENCENT = 'tencent', _('Tencent') + + +class BaseSMSClient: + """ + 短信终端的基类 + """ + + SIGN_AND_TMPL_SETTING_FIELD: str + + @property + def sign_and_tmpl(self): + return getattr(settings, self.SIGN_AND_TMPL_SETTING_FIELD, {}) + + @classmethod + def new_from_settings(cls): + raise NotImplementedError + + def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs): + raise NotImplementedError + + +class SMS: + client: BaseSMSClient + + def __init__(self, backend=None): + m = importlib.import_module(f'.{backend or settings.SMS_BACKEND}', __package__) + self.client = m.client.new_from_settings() + + def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs): + return self.client.send_sms( + phone_numbers=phone_numbers, + sign_name=sign_name, + template_code=template_code, + template_param=template_param, + **kwargs + ) + + def send_verify_code(self, phone_number, code): + sign_name, template_code = SMS_MESSAGE.VERIFICATION_CODE.get_sign_and_tmpl(self.client.sign_and_tmpl) + return self.send_sms([phone_number], sign_name, template_code, OrderedDict(code=code)) diff --git a/apps/common/message/backends/sms/alibaba.py b/apps/common/message/backends/sms/alibaba.py new file mode 100644 index 000000000..b664f4401 --- /dev/null +++ b/apps/common/message/backends/sms/alibaba.py @@ -0,0 +1,61 @@ +import json + +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from alibabacloud_dysmsapi20170525.client import Client as Dysmsapi20170525Client +from alibabacloud_tea_openapi import models as open_api_models +from alibabacloud_dysmsapi20170525 import models as dysmsapi_20170525_models +from Tea.exceptions import TeaException + +from common.utils import get_logger +from common.exceptions import JMSException +from . import BaseSMSClient + +logger = get_logger(__file__) + + +class AlibabaSMS(BaseSMSClient): + SIGN_AND_TMPL_SETTING_FIELD = 'ALIBABA_SMS_SIGN_AND_TEMPLATES' + + @classmethod + def new_from_settings(cls): + return cls( + access_key_id=settings.ALIBABA_ACCESS_KEY_ID, + access_key_secret=settings.ALIBABA_ACCESS_KEY_SECRET + ) + + def __init__(self, access_key_id: str, access_key_secret: str): + config = open_api_models.Config( + # 您的AccessKey ID, + access_key_id=access_key_id, + # 您的AccessKey Secret, + access_key_secret=access_key_secret + ) + # 访问的域名 + config.endpoint = 'dysmsapi.aliyuncs.com' + self.client = Dysmsapi20170525Client(config) + + def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs): + phone_numbers_str = ','.join(phone_numbers) + send_sms_request = dysmsapi_20170525_models.SendSmsRequest( + phone_numbers=phone_numbers_str, sign_name=sign_name, + template_code=template_code, template_param=json.dumps(template_param) + ) + try: + logger.info(f'Alibaba sms send: ' + f'phone_numbers={phone_numbers} ' + f'sign_name={sign_name} ' + f'template_code={template_code} ' + f'template_param={template_param}') + response = self.client.send_sms(send_sms_request) + # 这里只判断是否成功,失败抛出异常 + if response.body.code != 'OK': + raise JMSException(detail=response.body.message, code=response.body.code) + except TeaException as e: + if e.code == 'SignatureDoesNotMatch': + raise JMSException(code=e.code, detail=_('Signature does not match')) + raise JMSException(code=e.code, detail=e.message) + return response + + +client = AlibabaSMS diff --git a/apps/common/message/backends/sms/tencent.py b/apps/common/message/backends/sms/tencent.py new file mode 100644 index 000000000..6f796bb15 --- /dev/null +++ b/apps/common/message/backends/sms/tencent.py @@ -0,0 +1,90 @@ +import json +from collections import OrderedDict + +from django.conf import settings +from common.exceptions import JMSException +from common.utils import get_logger +from tencentcloud.common import credential +from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException +# 导入对应产品模块的client models。 +from tencentcloud.sms.v20210111 import sms_client, models +# 导入可选配置类 +from tencentcloud.common.profile.client_profile import ClientProfile +from tencentcloud.common.profile.http_profile import HttpProfile +from . import BaseSMSClient + +logger = get_logger(__file__) + + +class TencentSMS(BaseSMSClient): + """ + https://cloud.tencent.com/document/product/382/43196#.E5.8F.91.E9.80.81.E7.9F.AD.E4.BF.A1 + """ + SIGN_AND_TMPL_SETTING_FIELD = 'TENCENT_SMS_SIGN_AND_TEMPLATES' + + @classmethod + def new_from_settings(cls): + return cls( + secret_id=settings.TENCENT_SECRET_ID, + secret_key=settings.TENCENT_SECRET_KEY, + sdkappid=settings.TENCENT_SDKAPPID + ) + + def __init__(self, secret_id: str, secret_key: str, sdkappid: str): + self.sdkappid = sdkappid + + cred = credential.Credential(secret_id, secret_key) + httpProfile = HttpProfile() + httpProfile.reqMethod = "POST" # post请求(默认为post请求) + httpProfile.reqTimeout = 30 # 请求超时时间,单位为秒(默认60秒) + httpProfile.endpoint = "sms.tencentcloudapi.com" + + clientProfile = ClientProfile() + clientProfile.signMethod = "TC3-HMAC-SHA256" # 指定签名算法 + clientProfile.language = "en-US" + clientProfile.httpProfile = httpProfile + self.client = sms_client.SmsClient(cred, "ap-guangzhou", clientProfile) + + def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: OrderedDict, **kwargs): + try: + req = models.SendSmsRequest() + # 基本类型的设置: + # SDK采用的是指针风格指定参数,即使对于基本类型你也需要用指针来对参数赋值。 + # SDK提供对基本类型的指针引用封装函数 + # 帮助链接: + # 短信控制台: https://console.cloud.tencent.com/smsv2 + # sms helper: https://cloud.tencent.com/document/product/382/3773 + + # 短信应用ID: 短信SdkAppId在 [短信控制台] 添加应用后生成的实际SdkAppId,示例如1400006666 + req.SmsSdkAppId = self.sdkappid + # 短信签名内容: 使用 UTF-8 编码,必须填写已审核通过的签名,签名信息可登录 [短信控制台] 查看 + req.SignName = sign_name + # 短信码号扩展号: 默认未开通,如需开通请联系 [sms helper] + req.ExtendCode = "" + # 用户的 session 内容: 可以携带用户侧 ID 等上下文信息,server 会原样返回 + req.SessionContext = "Jumpserver" + # 国际/港澳台短信 senderid: 国内短信填空,默认未开通,如需开通请联系 [sms helper] + req.SenderId = "" + # 下发手机号码,采用 E.164 标准,+[国家或地区码][手机号] + # 示例如:+8613711112222, 其中前面有一个+号 ,86为国家码,13711112222为手机号,最多不要超过200个手机号 + req.PhoneNumberSet = phone_numbers + # 模板 ID: 必须填写已审核通过的模板 ID。模板ID可登录 [短信控制台] 查看 + req.TemplateId = template_code + # 模板参数: 若无模板参数,则设置为空 + req.TemplateParamSet = list(template_param.values()) + # 通过client对象调用DescribeInstances方法发起请求。注意请求方法名与请求对象是对应的。 + # 返回的resp是一个DescribeInstancesResponse类的实例,与请求对象对应。 + logger.info(f'Tencent sms send: ' + f'phone_numbers={phone_numbers} ' + f'sign_name={sign_name} ' + f'template_code={template_code} ' + f'template_param={template_param}') + + resp = self.client.SendSms(req) + + return resp + except TencentCloudSDKException as e: + raise JMSException(code=e.code, detail=e.message) + + +client = TencentSMS diff --git a/apps/common/message/backends/wecom/__init__.py b/apps/common/message/backends/wecom/__init__.py index 661a8276c..8ba593d2c 100644 --- a/apps/common/message/backends/wecom/__init__.py +++ b/apps/common/message/backends/wecom/__init__.py @@ -115,6 +115,7 @@ class WeCom(RequestMixin): }, **extra_params } + logger.info(f'Wecom send text: users={users} msg={msg}') data = self._requests.post(URL.SEND_MESSAGE, json=body, check_errcode_is_0=False) errcode = data['errcode'] diff --git a/apps/common/permissions.py b/apps/common/permissions.py index 71dca0b3c..b0f217c1c 100644 --- a/apps/common/permissions.py +++ b/apps/common/permissions.py @@ -191,3 +191,11 @@ class HasQueryParamsUserAndIsCurrentOrgMember(permissions.BasePermission): return False query_user = current_org.get_members().filter(id=query_user_id).first() return bool(query_user) + + +class OnlySuperUserCanList(IsValidUser): + def has_permission(self, request, view): + user = request.user + if view.action == 'list' and not user.is_superuser: + return False + return True diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 311a58159..74fd3b708 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -179,13 +179,14 @@ class Config(dict): 'AUTH_OPENID_CLIENT_SECRET': 'client-secret', 'AUTH_OPENID_SHARE_SESSION': True, 'AUTH_OPENID_IGNORE_SSL_VERIFICATION': True, + # OpenID 新配置参数 (version >= 1.5.9) - 'AUTH_OPENID_PROVIDER_ENDPOINT': 'https://op-example.com/', - 'AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT': 'https://op-example.com/authorize', - 'AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT': 'https://op-example.com/token', - 'AUTH_OPENID_PROVIDER_JWKS_ENDPOINT': 'https://op-example.com/jwks', - 'AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT': 'https://op-example.com/userinfo', - 'AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT': 'https://op-example.com/logout', + 'AUTH_OPENID_PROVIDER_ENDPOINT': 'https://oidc.example.com/', + 'AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT': 'https://oidc.example.com/authorize', + 'AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT': 'https://oidc.example.com/token', + 'AUTH_OPENID_PROVIDER_JWKS_ENDPOINT': 'https://oidc.example.com/jwks', + 'AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT': 'https://oidc.example.com/userinfo', + 'AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT': 'https://oidc.example.com/logout', 'AUTH_OPENID_PROVIDER_SIGNATURE_ALG': 'HS256', 'AUTH_OPENID_PROVIDER_SIGNATURE_KEY': None, 'AUTH_OPENID_SCOPES': 'openid profile email', @@ -194,10 +195,13 @@ class Config(dict): 'AUTH_OPENID_USE_STATE': True, 'AUTH_OPENID_USE_NONCE': True, 'AUTH_OPENID_ALWAYS_UPDATE_USER': True, - # OpenID 旧配置参数 (version <= 1.5.8 (discarded)) - 'AUTH_OPENID_SERVER_URL': 'http://openid', + + # Keycloak 旧配置参数 (version <= 1.5.8 (discarded)) + 'AUTH_OPENID_KEYCLOAK': True, + 'AUTH_OPENID_SERVER_URL': 'https://keycloak.example.com', 'AUTH_OPENID_REALM_NAME': None, + # Raidus 认证 'AUTH_RADIUS': False, 'RADIUS_SERVER': 'localhost', 'RADIUS_PORT': 1812, @@ -205,8 +209,9 @@ class Config(dict): 'RADIUS_ENCRYPT_PASSWORD': True, 'OTP_IN_RADIUS': False, + # Cas 认证 'AUTH_CAS': False, - 'CAS_SERVER_URL': "http://host/cas/", + 'CAS_SERVER_URL': "https://example.com/cas/", 'CAS_ROOT_PROXIED_AS': '', 'CAS_LOGOUT_COMPLETELY': True, 'CAS_VERSION': 3, @@ -218,24 +223,44 @@ class Config(dict): 'AUTH_SSO': False, 'AUTH_SSO_AUTHKEY_TTL': 60 * 15, + # 企业微信 'AUTH_WECOM': False, 'WECOM_CORPID': '', 'WECOM_AGENTID': '', 'WECOM_SECRET': '', + # 钉钉 'AUTH_DINGTALK': False, 'DINGTALK_AGENTID': '', 'DINGTALK_APPKEY': '', 'DINGTALK_APPSECRET': '', + # 飞书 'AUTH_FEISHU': False, 'FEISHU_APP_ID': '', 'FEISHU_APP_SECRET': '', + 'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS + 'LOGIN_REDIRECT_MSG_ENABLED': True, + + 'SMS_ENABLED': False, + 'SMS_BACKEND': '', + 'SMS_TEST_PHONE': '', + + 'ALIBABA_ACCESS_KEY_ID': '', + 'ALIBABA_ACCESS_KEY_SECRET': '', + 'ALIBABA_SMS_SIGN_AND_TEMPLATES': {}, + + 'TENCENT_SECRET_ID': '', + 'TENCENT_SECRET_KEY': '', + 'TENCENT_SDKAPPID': '', + 'TENCENT_SMS_SIGN_AND_TEMPLATES': {}, + 'OTP_VALID_WINDOW': 2, 'OTP_ISSUER_NAME': 'JumpServer', - 'EMAIL_SUFFIX': 'jumpserver.org', + 'EMAIL_SUFFIX': 'example.com', + # Terminal配置 'TERMINAL_PASSWORD_AUTH': True, 'TERMINAL_PUBLIC_KEY_AUTH': True, 'TERMINAL_HEARTBEAT_INTERVAL': 20, @@ -245,7 +270,10 @@ class Config(dict): 'TERMINAL_HOST_KEY': '', 'TERMINAL_TELNET_REGEX': '', 'TERMINAL_COMMAND_STORAGE': {}, + 'TERMINAL_RDP_ADDR': '', + 'XRDP_ENABLED': True, + # 安全配置 'SECURITY_MFA_AUTH': 0, # 0 不开启 1 全局开启 2 管理员开启 'SECURITY_COMMAND_EXECUTION': True, 'SECURITY_SERVICE_ACCOUNT_REGISTRATION': True, @@ -262,57 +290,60 @@ class Config(dict): 'SECURITY_PASSWORD_SPECIAL_CHAR': False, 'SECURITY_LOGIN_CHALLENGE_ENABLED': False, 'SECURITY_LOGIN_CAPTCHA_ENABLED': True, - 'SECURITY_DATA_CRYPTO_ALGO': 'aes', 'SECURITY_INSECURE_COMMAND': False, 'SECURITY_INSECURE_COMMAND_LEVEL': 5, 'SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER': '', 'SECURITY_LUNA_REMEMBER_AUTH': True, 'SECURITY_WATERMARK_ENABLED': True, + 'SECURITY_MFA_VERIFY_TTL': 3600, + 'SECURITY_SESSION_SHARE': True, + 'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5, + 'LOGIN_CONFIRM_ENABLE': False, # 准备废弃,放到 acl 中 + 'CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED': True, + 'USER_LOGIN_SINGLE_MACHINE_ENABLED': False, + 'ONLY_ALLOW_EXIST_USER_AUTH': False, + 'ONLY_ALLOW_AUTH_FROM_SOURCE': False, + # 启动前 'HTTP_BIND_HOST': '0.0.0.0', 'HTTP_LISTEN_PORT': 8080, 'WS_LISTEN_PORT': 8070, + 'SYSLOG_ADDR': '', # '192.168.0.1:514' + 'SYSLOG_FACILITY': 'user', + 'SYSLOG_SOCKTYPE': 2, + 'PERM_EXPIRED_CHECK_PERIODIC': 60 * 60, + 'FLOWER_URL': "127.0.0.1:5555", + 'LANGUAGE_CODE': 'zh', + 'TIME_ZONE': 'Asia/Shanghai', + 'FORCE_SCRIPT_NAME': '', + 'SESSION_COOKIE_SECURE': False, + 'CSRF_COOKIE_SECURE': False, + 'REFERER_CHECK_ENABLED': False, + 'SESSION_SAVE_EVERY_REQUEST': True, + 'SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE': False, + 'SERVER_REPLAY_STORAGE': {}, + 'SECURITY_DATA_CRYPTO_ALGO': 'aes', + + # 记录清理清理 'LOGIN_LOG_KEEP_DAYS': 200, 'TASK_LOG_KEEP_DAYS': 90, 'OPERATE_LOG_KEEP_DAYS': 200, 'FTP_LOG_KEEP_DAYS': 200, - 'ASSETS_PERM_CACHE_TIME': 3600 * 24, - 'SECURITY_MFA_VERIFY_TTL': 3600, - 'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5, - 'ASSETS_PERM_CACHE_ENABLE': HAS_XPACK, - 'SYSLOG_ADDR': '', # '192.168.0.1:514' - 'SYSLOG_FACILITY': 'user', - 'SYSLOG_SOCKTYPE': 2, - 'PERM_SINGLE_ASSET_TO_UNGROUP_NODE': False, - 'PERM_EXPIRED_CHECK_PERIODIC': 60 * 60, - 'WINDOWS_SSH_DEFAULT_SHELL': 'cmd', - 'FLOWER_URL': "127.0.0.1:5555", - 'DEFAULT_ORG_SHOW_ALL_USERS': True, - 'PERIOD_TASK_ENABLED': True, - 'FORCE_SCRIPT_NAME': '', - 'LOGIN_CONFIRM_ENABLE': False, - 'WINDOWS_SKIP_ALL_MANUAL_PASSWORD': False, - 'ORG_CHANGE_TO_URL': '', - 'LANGUAGE_CODE': 'zh', - 'TIME_ZONE': 'Asia/Shanghai', - 'CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED': True, - 'USER_LOGIN_SINGLE_MACHINE_ENABLED': False, - 'TICKETS_ENABLED': True, - 'SESSION_COOKIE_SECURE': False, - 'CSRF_COOKIE_SECURE': False, - 'REFERER_CHECK_ENABLED': False, - 'SERVER_REPLAY_STORAGE': {}, - 'CONNECTION_TOKEN_ENABLED': False, - 'ONLY_ALLOW_EXIST_USER_AUTH': False, - 'ONLY_ALLOW_AUTH_FROM_SOURCE': False, - 'SESSION_SAVE_EVERY_REQUEST': True, - 'SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE': False, - 'FORGOT_PASSWORD_URL': '', - 'HEALTH_CHECK_TOKEN': '', - 'LOGIN_REDIRECT_TO_BACKEND': None, # 'OPENID / CAS 'CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS': 30, - 'TERMINAL_RDP_ADDR': '' + # 废弃的 + 'DEFAULT_ORG_SHOW_ALL_USERS': True, + 'ORG_CHANGE_TO_URL': '', + 'WINDOWS_SKIP_ALL_MANUAL_PASSWORD': False, + 'CONNECTION_TOKEN_ENABLED': False, + + 'PERM_SINGLE_ASSET_TO_UNGROUP_NODE': False, + 'WINDOWS_SSH_DEFAULT_SHELL': 'cmd', + 'PERIOD_TASK_ENABLED': True, + + 'TICKETS_ENABLED': True, + 'FORGOT_PASSWORD_URL': '', + 'HEALTH_CHECK_TOKEN': '', } def compatible_auth_openid_of_key(self): @@ -323,6 +354,9 @@ class Config(dict): 构造出新配置中标准OpenID协议中所需的Endpoint即可 (Keycloak说明文档参考: https://www.keycloak.org/docs/latest/securing_apps/) """ + if self.AUTH_OPENID and not self.AUTH_OPENID_REALM_NAME: + self['AUTH_OPENID_KEYCLOAK'] = False + if not self.AUTH_OPENID: return diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index d8e96e673..ac9fdb631 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -122,6 +122,22 @@ AUTH_FEISHU = CONFIG.AUTH_FEISHU FEISHU_APP_ID = CONFIG.FEISHU_APP_ID FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET +# SMS auth +SMS_ENABLED = CONFIG.SMS_ENABLED +SMS_BACKEND = CONFIG.SMS_BACKEND +SMS_TEST_PHONE = CONFIG.SMS_TEST_PHONE + +# Alibaba +ALIBABA_ACCESS_KEY_ID = CONFIG.ALIBABA_ACCESS_KEY_ID +ALIBABA_ACCESS_KEY_SECRET = CONFIG.ALIBABA_ACCESS_KEY_SECRET +ALIBABA_SMS_SIGN_AND_TEMPLATES = CONFIG.ALIBABA_SMS_SIGN_AND_TEMPLATES + +# TENCENT +TENCENT_SECRET_ID = CONFIG.TENCENT_SECRET_ID +TENCENT_SECRET_KEY = CONFIG.TENCENT_SECRET_KEY +TENCENT_SDKAPPID = CONFIG.TENCENT_SDKAPPID +TENCENT_SMS_SIGN_AND_TEMPLATES = CONFIG.TENCENT_SMS_SIGN_AND_TEMPLATES + # Other setting TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION LOGIN_CONFIRM_ENABLE = CONFIG.LOGIN_CONFIRM_ENABLE diff --git a/apps/jumpserver/settings/base.py b/apps/jumpserver/settings/base.py index a3b2c7cf6..fd8feeaad 100644 --- a/apps/jumpserver/settings/base.py +++ b/apps/jumpserver/settings/base.py @@ -87,6 +87,7 @@ MIDDLEWARE = [ 'orgs.middleware.OrgMiddleware', 'authentication.backends.oidc.middleware.OIDCRefreshIDTokenMiddleware', 'authentication.backends.cas.middleware.CASMiddleware', + 'authentication.middleware.MFAMiddleware', 'simple_history.middleware.HistoryRequestMiddleware', ] diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 2e31eb536..0ef447621 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -72,14 +72,9 @@ TERMINAL_HOST_KEY = CONFIG.TERMINAL_HOST_KEY TERMINAL_HEADER_TITLE = CONFIG.TERMINAL_HEADER_TITLE TERMINAL_TELNET_REGEX = CONFIG.TERMINAL_TELNET_REGEX -# User or user group permission cache time, default 3600 seconds -ASSETS_PERM_CACHE_ENABLE = CONFIG.ASSETS_PERM_CACHE_ENABLE -ASSETS_PERM_CACHE_TIME = CONFIG.ASSETS_PERM_CACHE_TIME - # Asset user auth external backend, default AuthBook backend BACKEND_ASSET_USER_AUTH_VAULT = False -DEFAULT_ORG_SHOW_ALL_USERS = CONFIG.DEFAULT_ORG_SHOW_ALL_USERS PERM_SINGLE_ASSET_TO_UNGROUP_NODE = CONFIG.PERM_SINGLE_ASSET_TO_UNGROUP_NODE PERM_EXPIRED_CHECK_PERIODIC = CONFIG.PERM_EXPIRED_CHECK_PERIODIC WINDOWS_SSH_DEFAULT_SHELL = CONFIG.WINDOWS_SSH_DEFAULT_SHELL @@ -129,7 +124,11 @@ HEALTH_CHECK_TOKEN = CONFIG.HEALTH_CHECK_TOKEN TERMINAL_RDP_ADDR = CONFIG.TERMINAL_RDP_ADDR SECURITY_LUNA_REMEMBER_AUTH = CONFIG.SECURITY_LUNA_REMEMBER_AUTH SECURITY_WATERMARK_ENABLED = CONFIG.SECURITY_WATERMARK_ENABLED +SECURITY_SESSION_SHARE = CONFIG.SECURITY_SESSION_SHARE LOGIN_REDIRECT_TO_BACKEND = CONFIG.LOGIN_REDIRECT_TO_BACKEND +LOGIN_REDIRECT_MSG_ENABLED = CONFIG.LOGIN_REDIRECT_MSG_ENABLED CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS = CONFIG.CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS + +XRDP_ENABLED = CONFIG.XRDP_ENABLED diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 43d0e6cb0..ea41cc7ee 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -32,6 +32,7 @@ app_view_patterns = [ path('ops/', include('ops.urls.view_urls'), name='ops'), path('common/', include('common.urls.view_urls'), name='common'), re_path(r'flower/(?P.*)', views.celery_flower_view, name='flower-view'), + path('download/', views.ResourceDownload.as_view(), name='download') ] if settings.XPACK_ENABLED: diff --git a/apps/jumpserver/views/other.py b/apps/jumpserver/views/other.py index 293177615..9cf5a5500 100644 --- a/apps/jumpserver/views/other.py +++ b/apps/jumpserver/views/other.py @@ -4,7 +4,7 @@ import re from django.http import HttpResponseRedirect, JsonResponse, Http404 from django.conf import settings -from django.views.generic import View +from django.views.generic import View, TemplateView from django.shortcuts import redirect from django.utils.translation import ugettext_lazy as _ from django.views.decorators.csrf import csrf_exempt @@ -16,7 +16,8 @@ from common.http import HttpResponseTemporaryRedirect __all__ = [ 'LunaView', 'I18NView', 'KokoView', 'WsView', - 'redirect_format_api', 'redirect_old_apps_view', 'UIView' + 'redirect_format_api', 'redirect_old_apps_view', 'UIView', + 'ResourceDownload', ] @@ -84,3 +85,6 @@ class KokoView(View): "If you see this page, prove that you are not accessing the nginx listening port. Good luck.") return HttpResponse(msg) + +class ResourceDownload(TemplateView): + template_name = 'resource_download.html' diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 2f75f059b..c873509ac 100644 Binary files a/apps/locale/zh/LC_MESSAGES/django.mo and b/apps/locale/zh/LC_MESSAGES/django.mo differ diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index f9bf84e0c..e36335bda 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-08-18 18:56+0800\n" +"POT-Creation-Date: 2021-09-09 20:13+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -22,10 +22,10 @@ msgstr "" #: assets/models/base.py:175 assets/models/cluster.py:18 #: assets/models/cmd_filter.py:21 assets/models/domain.py:24 #: assets/models/group.py:20 assets/models/label.py:18 ops/mixin.py:24 -#: orgs/models.py:24 perms/models/base.py:49 settings/models.py:29 -#: terminal/models/storage.py:23 terminal/models/task.py:16 -#: terminal/models/terminal.py:100 users/forms/profile.py:32 -#: users/models/group.py:15 users/models/user.py:561 +#: orgs/models.py:24 perms/models/base.py:44 settings/models.py:29 +#: settings/serializers/sms.py:6 terminal/models/storage.py:23 +#: terminal/models/task.py:16 terminal/models/terminal.py:100 +#: users/forms/profile.py:32 users/models/group.py:15 users/models/user.py:604 #: users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_asset_permission.html:37 #: users/templates/users/user_asset_permission.html:154 @@ -35,39 +35,38 @@ msgid "Name" msgstr "名称" #: acls/models/base.py:27 assets/models/cmd_filter.py:54 -#: assets/models/user.py:203 +#: assets/models/user.py:207 msgid "Priority" msgstr "优先级" #: acls/models/base.py:28 assets/models/cmd_filter.py:54 -#: assets/models/user.py:203 +#: assets/models/user.py:207 msgid "1-100, the lower the value will be match first" msgstr "优先级可选范围为 1-100 (数值越小越优先)" #: acls/models/base.py:31 authentication/models.py:20 #: authentication/templates/authentication/_access_key_modal.html:32 -#: perms/models/base.py:52 users/templates/users/_select_user_modal.html:18 +#: perms/models/base.py:48 terminal/models/sharing.py:24 +#: users/templates/users/_select_user_modal.html:18 msgid "Active" msgstr "激活中" -# msgid "Date created" -# msgstr "创建日期" #: acls/models/base.py:32 applications/models/application.py:179 #: assets/models/asset.py:144 assets/models/asset.py:220 #: assets/models/base.py:180 assets/models/cluster.py:29 #: assets/models/cmd_filter.py:23 assets/models/cmd_filter.py:64 #: assets/models/domain.py:25 assets/models/domain.py:65 #: assets/models/group.py:23 assets/models/label.py:23 ops/models/adhoc.py:37 -#: orgs/models.py:27 perms/models/base.py:57 settings/models.py:34 +#: orgs/models.py:27 perms/models/base.py:53 settings/models.py:34 #: terminal/models/storage.py:26 terminal/models/terminal.py:114 -#: tickets/models/ticket.py:73 users/models/group.py:16 -#: users/models/user.py:594 xpack/plugins/change_auth_plan/models.py:88 -#: xpack/plugins/cloud/models.py:35 xpack/plugins/cloud/models.py:113 +#: tickets/models/ticket.py:71 users/models/group.py:16 +#: users/models/user.py:637 xpack/plugins/change_auth_plan/models.py:77 +#: xpack/plugins/cloud/models.py:35 xpack/plugins/cloud/models.py:108 #: xpack/plugins/gathered_user/models.py:26 msgid "Comment" msgstr "备注" -#: acls/models/login_acl.py:16 tickets/const.py:19 +#: acls/models/login_acl.py:16 tickets/const.py:38 msgid "Reject" msgstr "拒绝" @@ -83,7 +82,7 @@ msgstr "登录IP" #: acls/serializers/login_acl.py:34 acls/serializers/login_asset_acl.py:75 #: assets/models/cmd_filter.py:57 audits/models.py:57 #: authentication/templates/authentication/_access_key_modal.html:34 -#: tickets/models/ticket.py:43 users/templates/users/_granted_assets.html:29 +#: users/templates/users/_granted_assets.html:29 #: users/templates/users/user_asset_permission.html:44 #: users/templates/users/user_asset_permission.html:79 #: users/templates/users/user_database_app_permission.html:42 @@ -94,12 +93,12 @@ msgstr "动作" #: acls/serializers/login_acl.py:33 assets/models/label.py:15 #: audits/models.py:36 audits/models.py:56 audits/models.py:74 #: audits/serializers.py:93 authentication/models.py:44 -#: authentication/models.py:97 orgs/models.py:19 orgs/models.py:433 -#: perms/models/base.py:50 templates/index.html:78 +#: authentication/models.py:96 orgs/models.py:19 orgs/models.py:433 +#: perms/models/base.py:45 templates/index.html:78 #: terminal/backends/command/models.py:18 #: terminal/backends/command/serializers.py:12 terminal/models/session.py:38 -#: tickets/models/comment.py:17 users/const.py:14 users/models/user.py:176 -#: users/models/user.py:762 users/models/user.py:788 +#: tickets/models/comment.py:17 users/const.py:14 users/models/user.py:181 +#: users/models/user.py:809 users/models/user.py:835 #: users/serializers/group.py:19 #: users/templates/users/user_asset_permission.html:38 #: users/templates/users/user_asset_permission.html:64 @@ -120,14 +119,14 @@ msgstr "系统用户" #: acls/models/login_asset_acl.py:22 #: applications/serializers/attrs/application_category/remote_app.py:37 #: assets/models/asset.py:357 assets/models/authbook.py:15 -#: assets/models/gathered_user.py:14 assets/serializers/system_user.py:200 +#: assets/models/gathered_user.py:14 assets/serializers/system_user.py:201 #: audits/models.py:38 perms/models/asset_permission.py:99 #: templates/index.html:82 terminal/backends/command/models.py:19 #: terminal/backends/command/serializers.py:13 terminal/models/session.py:40 #: users/templates/users/user_asset_permission.html:40 #: users/templates/users/user_asset_permission.html:70 -#: xpack/plugins/change_auth_plan/models.py:315 -#: xpack/plugins/cloud/models.py:217 +#: xpack/plugins/change_auth_plan/models.py:282 +#: xpack/plugins/cloud/models.py:212 msgid "Asset" msgstr "资产" @@ -156,7 +155,7 @@ msgstr "" #: acls/serializers/login_acl.py:30 acls/serializers/login_asset_acl.py:31 #: applications/serializers/attrs/application_type/mysql_workbench.py:18 #: assets/models/asset.py:180 assets/models/domain.py:61 -#: assets/serializers/account.py:12 settings/serializers/settings.py:114 +#: assets/serializers/account.py:12 settings/serializers/terminal.py:8 #: users/templates/users/_granted_assets.html:26 #: users/templates/users/user_asset_permission.html:156 msgid "IP" @@ -172,17 +171,16 @@ msgstr "格式为逗号分隔的字符串, * 表示匹配所有. " #: acls/serializers/login_asset_acl.py:17 #: acls/serializers/login_asset_acl.py:51 -#: applications/serializers/application.py:74 #: applications/serializers/attrs/application_type/chrome.py:20 #: applications/serializers/attrs/application_type/custom.py:21 #: applications/serializers/attrs/application_type/mysql_workbench.py:30 #: applications/serializers/attrs/application_type/vmware_client.py:26 #: assets/models/base.py:176 assets/models/gathered_user.py:15 #: audits/models.py:105 authentication/forms.py:15 authentication/forms.py:17 -#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:559 +#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:602 #: users/templates/users/_select_user_modal.html:14 -#: xpack/plugins/change_auth_plan/models.py:51 -#: xpack/plugins/change_auth_plan/models.py:311 +#: xpack/plugins/change_auth_plan/models.py:47 +#: xpack/plugins/change_auth_plan/models.py:278 #: xpack/plugins/cloud/serializers.py:67 msgid "Username" msgstr "用户名" @@ -198,7 +196,7 @@ msgstr "" #: acls/serializers/login_asset_acl.py:35 assets/models/asset.py:181 #: assets/serializers/account.py:13 assets/serializers/gathered_user.py:23 -#: settings/serializers/settings.py:113 +#: settings/serializers/terminal.py:7 #: users/templates/users/_granted_assets.html:25 #: users/templates/users/user_asset_permission.html:157 msgid "Hostname" @@ -211,7 +209,7 @@ msgid "" msgstr "格式为逗号分隔的字符串, * 表示匹配所有. 可选的协议有: {}" #: acls/serializers/login_asset_acl.py:55 assets/models/asset.py:184 -#: assets/models/domain.py:63 assets/models/user.py:204 +#: assets/models/domain.py:63 assets/models/user.py:208 #: terminal/serializers/session.py:30 terminal/serializers/storage.py:69 msgid "Protocol" msgstr "协议" @@ -221,7 +219,7 @@ msgid "Unsupported protocols: {}" msgstr "不支持的协议: {}" #: acls/serializers/login_asset_acl.py:98 -#: tickets/serializers/ticket/ticket.py:111 +#: tickets/serializers/ticket/ticket.py:105 msgid "The organization `{}` does not exist" msgstr "组织 `{}` 不存在" @@ -233,7 +231,7 @@ msgstr "所有复核人都不属于组织 `{}`" msgid "My applications" msgstr "我的应用" -#: applications/const.py:8 +#: applications/const.py:8 applications/models/account.py:10 #: applications/serializers/attrs/application_category/db.py:14 #: applications/serializers/attrs/application_type/mysql_workbench.py:26 msgid "Database" @@ -247,79 +245,8 @@ msgstr "远程应用" msgid "Custom" msgstr "自定义" -#: applications/models/application.py:50 templates/_nav.html:60 -msgid "Applications" -msgstr "应用管理" - -#: applications/models/application.py:168 -#: applications/serializers/application.py:80 assets/models/label.py:21 -#: perms/models/application_permission.py:20 -#: perms/serializers/application/user_permission.py:33 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:20 -msgid "Category" -msgstr "类别" - -#: applications/models/application.py:171 -#: applications/serializers/application.py:82 assets/models/cmd_filter.py:53 -#: assets/models/user.py:202 perms/models/application_permission.py:23 -#: perms/serializers/application/user_permission.py:34 -#: terminal/models/storage.py:55 terminal/models/storage.py:116 -#: tickets/models/ticket.py:38 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:27 -msgid "Type" -msgstr "类型" - -#: applications/models/application.py:175 assets/models/asset.py:188 -#: assets/models/domain.py:30 assets/models/domain.py:64 -msgid "Domain" -msgstr "网域" - -#: applications/models/application.py:177 xpack/plugins/cloud/models.py:33 -msgid "Attrs" -msgstr "" - -#: applications/serializers/application.py:50 -#: applications/serializers/application.py:81 assets/serializers/label.py:13 -#: perms/serializers/application/permission.py:16 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:24 -msgid "Category display" -msgstr "类别名称" - -#: applications/serializers/application.py:51 -#: applications/serializers/application.py:83 -#: assets/serializers/system_user.py:26 audits/serializers.py:29 -#: perms/serializers/application/permission.py:17 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:31 -#: tickets/serializers/ticket/ticket.py:19 -msgid "Type display" -msgstr "类型名称" - -#: applications/serializers/application.py:73 -msgid "Id" -msgstr "" - -#: applications/serializers/application.py:75 -#: applications/serializers/application.py:110 -#: applications/serializers/attrs/application_type/chrome.py:23 -#: applications/serializers/attrs/application_type/custom.py:25 -#: applications/serializers/attrs/application_type/mysql_workbench.py:34 -#: applications/serializers/attrs/application_type/vmware_client.py:30 -#: assets/models/base.py:177 audits/signals_handler.py:63 -#: authentication/forms.py:22 -#: authentication/templates/authentication/login.html:164 -#: settings/serializers/settings.py:95 users/forms/profile.py:21 -#: users/templates/users/user_otp_check_password.html:13 -#: users/templates/users/user_password_update.html:43 -#: users/templates/users/user_password_verify.html:18 -#: xpack/plugins/change_auth_plan/models.py:72 -#: xpack/plugins/change_auth_plan/models.py:207 -#: xpack/plugins/change_auth_plan/models.py:318 -#: xpack/plugins/cloud/serializers.py:69 -msgid "Password" -msgstr "密码" - -#: applications/serializers/application.py:76 assets/models/authbook.py:16 -#: assets/models/user.py:277 audits/models.py:39 +#: applications/models/account.py:11 assets/models/authbook.py:16 +#: assets/models/user.py:281 audits/models.py:39 #: perms/models/application_permission.py:31 #: perms/models/asset_permission.py:101 templates/_nav.html:45 #: terminal/backends/command/models.py:20 @@ -333,40 +260,76 @@ msgstr "密码" msgid "System user" msgstr "系统用户" -#: applications/serializers/application.py:77 assets/serializers/account.py:31 -msgid "System user display" -msgstr "系统用户名称" +#: applications/models/account.py:12 assets/models/authbook.py:17 +#: settings/serializers/auth/cas.py:14 +msgid "Version" +msgstr "版本" -#: applications/serializers/application.py:78 -msgid "App" -msgstr "应用" +#: applications/models/account.py:18 xpack/plugins/cloud/models.py:82 +#: xpack/plugins/cloud/serializers.py:204 +msgid "Account" +msgstr "账户" -#: applications/serializers/application.py:79 -msgid "Application name" +#: applications/models/application.py:50 templates/_nav.html:60 +msgid "Applications" +msgstr "应用管理" + +#: applications/models/application.py:168 +#: applications/serializers/application.py:82 assets/models/label.py:21 +#: perms/models/application_permission.py:20 +#: perms/serializers/application/user_permission.py:33 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:20 +msgid "Category" +msgstr "类别" + +#: applications/models/application.py:171 +#: applications/serializers/application.py:84 assets/models/cmd_filter.py:53 +#: assets/models/user.py:206 perms/models/application_permission.py:23 +#: perms/serializers/application/user_permission.py:34 +#: terminal/models/storage.py:55 terminal/models/storage.py:116 +#: tickets/models/flow.py:50 tickets/models/ticket.py:48 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:27 +msgid "Type" +msgstr "类型" + +#: applications/models/application.py:175 assets/models/asset.py:188 +#: assets/models/domain.py:30 assets/models/domain.py:64 +msgid "Domain" +msgstr "网域" + +#: applications/models/application.py:177 xpack/plugins/cloud/models.py:33 +msgid "Attrs" +msgstr "" + +#: applications/serializers/application.py:59 +#: applications/serializers/application.py:83 assets/serializers/label.py:13 +#: perms/serializers/application/permission.py:16 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:24 +msgid "Category display" +msgstr "类别名称" + +#: applications/serializers/application.py:60 +#: applications/serializers/application.py:85 +#: assets/serializers/system_user.py:26 audits/serializers.py:29 +#: perms/serializers/application/permission.py:17 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:31 +#: tickets/serializers/ticket/ticket.py:22 +#: tickets/serializers/ticket/ticket.py:162 +msgid "Type display" +msgstr "类型名称" + +#: applications/serializers/application.py:101 +msgid "Application display" msgstr "应用名称" -#: applications/serializers/application.py:84 -msgid "Union id" -msgstr "联合ID" - -#: applications/serializers/application.py:85 orgs/mixins/models.py:45 -#: orgs/mixins/serializers.py:25 orgs/models.py:37 orgs/models.py:432 -#: orgs/serializers.py:106 tickets/serializers/ticket/ticket.py:83 -msgid "Organization" -msgstr "组织" - -#: applications/serializers/application.py:86 assets/serializers/asset.py:97 -#: assets/serializers/system_user.py:217 orgs/mixins/serializers.py:26 -msgid "Org name" -msgstr "组织名称" - #: applications/serializers/attrs/application_category/cloud.py:9 #: assets/models/cluster.py:40 msgid "Cluster" msgstr "集群" #: applications/serializers/attrs/application_category/db.py:11 -#: ops/models/adhoc.py:146 xpack/plugins/cloud/serializers.py:65 +#: ops/models/adhoc.py:146 settings/serializers/auth/radius.py:14 +#: xpack/plugins/cloud/serializers.py:65 msgid "Host" msgstr "主机" @@ -376,7 +339,7 @@ msgstr "主机" #: applications/serializers/attrs/application_type/oracle.py:11 #: applications/serializers/attrs/application_type/pgsql.py:11 #: assets/models/asset.py:185 assets/models/domain.py:62 -#: xpack/plugins/cloud/serializers.py:66 +#: settings/serializers/auth/radius.py:15 xpack/plugins/cloud/serializers.py:66 msgid "Port" msgstr "端口" @@ -388,7 +351,6 @@ msgid "Application path" msgstr "应用路径" #: applications/serializers/attrs/application_category/remote_app.py:45 -#: xpack/plugins/cloud/serializers.py:51 msgid "This field is required." msgstr "该字段是必填项。" @@ -397,6 +359,24 @@ msgstr "该字段是必填项。" msgid "Target URL" msgstr "目标URL" +#: applications/serializers/attrs/application_type/chrome.py:23 +#: applications/serializers/attrs/application_type/custom.py:25 +#: applications/serializers/attrs/application_type/mysql_workbench.py:34 +#: applications/serializers/attrs/application_type/vmware_client.py:30 +#: assets/models/base.py:177 audits/signals_handler.py:63 +#: authentication/forms.py:22 +#: authentication/templates/authentication/login.html:164 +#: settings/serializers/auth/ldap.py:44 users/forms/profile.py:21 +#: users/templates/users/user_otp_check_password.html:13 +#: users/templates/users/user_password_update.html:43 +#: users/templates/users/user_password_verify.html:18 +#: xpack/plugins/change_auth_plan/models.py:68 +#: xpack/plugins/change_auth_plan/models.py:190 +#: xpack/plugins/change_auth_plan/models.py:285 +#: xpack/plugins/cloud/serializers.py:69 +msgid "Password" +msgstr "密码" + #: applications/serializers/attrs/application_type/custom.py:13 msgid "Operating parameter" msgstr "运行参数" @@ -429,8 +409,8 @@ msgstr "基础" msgid "Charset" msgstr "编码" -#: assets/models/asset.py:142 assets/serializers/asset.py:161 -#: tickets/models/ticket.py:40 +#: assets/models/asset.py:142 assets/serializers/asset.py:168 +#: tickets/models/ticket.py:50 msgid "Meta" msgstr "元数据" @@ -445,13 +425,13 @@ msgstr "系统平台" #: assets/models/asset.py:186 assets/serializers/asset.py:65 #: perms/serializers/asset/user_permission.py:41 -#: xpack/plugins/cloud/models.py:104 xpack/plugins/cloud/serializers.py:184 +#: xpack/plugins/cloud/models.py:99 xpack/plugins/cloud/serializers.py:183 msgid "Protocols" msgstr "协议组" -#: assets/models/asset.py:189 assets/models/user.py:194 +#: assets/models/asset.py:189 assets/models/user.py:198 #: perms/models/asset_permission.py:100 -#: xpack/plugins/change_auth_plan/models.py:60 +#: xpack/plugins/change_auth_plan/models.py:56 #: xpack/plugins/gathered_user/models.py:24 msgid "Nodes" msgstr "节点" @@ -463,7 +443,8 @@ msgid "Is active" msgstr "激活" #: assets/models/asset.py:193 assets/models/cluster.py:19 -#: assets/models/user.py:191 assets/models/user.py:326 templates/_nav.html:44 +#: assets/models/user.py:195 assets/models/user.py:330 templates/_nav.html:44 +#: xpack/plugins/cloud/models.py:96 xpack/plugins/cloud/serializers.py:205 msgid "Admin user" msgstr "特权用户" @@ -539,29 +520,23 @@ msgstr "标签管理" #: assets/models/cluster.py:28 assets/models/cmd_filter.py:26 #: assets/models/cmd_filter.py:67 assets/models/group.py:21 #: common/db/models.py:70 common/mixins/models.py:49 orgs/models.py:25 -#: orgs/models.py:437 perms/models/base.py:55 users/models/user.py:602 -#: users/serializers/group.py:33 xpack/plugins/change_auth_plan/models.py:92 -#: xpack/plugins/cloud/models.py:119 xpack/plugins/gathered_user/models.py:30 +#: orgs/models.py:437 perms/models/base.py:51 users/models/user.py:645 +#: users/serializers/group.py:33 xpack/plugins/change_auth_plan/models.py:81 +#: xpack/plugins/cloud/models.py:114 xpack/plugins/gathered_user/models.py:30 msgid "Created by" msgstr "创建者" -# msgid "Created by" -# msgstr "创建者" #: assets/models/asset.py:219 assets/models/base.py:181 #: assets/models/cluster.py:26 assets/models/domain.py:27 #: assets/models/gathered_user.py:19 assets/models/group.py:22 #: assets/models/label.py:25 common/db/models.py:72 common/mixins/models.py:50 #: ops/models/adhoc.py:38 ops/models/command.py:29 orgs/models.py:26 -#: orgs/models.py:435 perms/models/base.py:56 users/models/group.py:18 -#: users/models/user.py:789 xpack/plugins/cloud/models.py:122 +#: orgs/models.py:435 perms/models/base.py:52 users/models/group.py:18 +#: users/models/user.py:836 xpack/plugins/cloud/models.py:117 msgid "Date created" msgstr "创建日期" -#: assets/models/authbook.py:17 -msgid "Version" -msgstr "版本" - -#: assets/models/authbook.py:24 +#: assets/models/authbook.py:23 msgid "AuthBook" msgstr "账号" @@ -586,15 +561,15 @@ msgstr "可连接性" msgid "Date verified" msgstr "校验日期" -#: assets/models/base.py:178 xpack/plugins/change_auth_plan/models.py:82 -#: xpack/plugins/change_auth_plan/models.py:214 -#: xpack/plugins/change_auth_plan/models.py:325 +#: assets/models/base.py:178 xpack/plugins/change_auth_plan/models.py:72 +#: xpack/plugins/change_auth_plan/models.py:197 +#: xpack/plugins/change_auth_plan/models.py:292 msgid "SSH private key" msgstr "SSH密钥" -#: assets/models/base.py:179 xpack/plugins/change_auth_plan/models.py:85 -#: xpack/plugins/change_auth_plan/models.py:210 -#: xpack/plugins/change_auth_plan/models.py:321 +#: assets/models/base.py:179 xpack/plugins/change_auth_plan/models.py:75 +#: xpack/plugins/change_auth_plan/models.py:193 +#: xpack/plugins/change_auth_plan/models.py:288 msgid "SSH public key" msgstr "SSH公钥" @@ -612,7 +587,7 @@ msgstr "带宽" msgid "Contact" msgstr "联系人" -#: assets/models/cluster.py:22 users/models/user.py:580 +#: assets/models/cluster.py:22 users/models/user.py:623 msgid "Phone" msgstr "手机" @@ -638,7 +613,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:774 +#: users/models/user.py:821 msgid "System" msgstr "系统" @@ -646,7 +621,7 @@ msgstr "系统" msgid "Default Cluster" msgstr "默认Cluster" -#: assets/models/cmd_filter.py:33 assets/models/user.py:209 +#: assets/models/cmd_filter.py:33 assets/models/user.py:213 msgid "Command filter" msgstr "命令过滤器" @@ -748,73 +723,73 @@ msgstr "全称" msgid "Parent key" msgstr "ssh私钥" -#: assets/models/node.py:559 assets/serializers/system_user.py:199 +#: assets/models/node.py:559 assets/serializers/system_user.py:200 #: users/templates/users/user_asset_permission.html:41 #: users/templates/users/user_asset_permission.html:73 #: users/templates/users/user_asset_permission.html:158 -#: xpack/plugins/cloud/models.py:93 xpack/plugins/cloud/serializers.py:210 +#: xpack/plugins/cloud/models.py:93 xpack/plugins/cloud/serializers.py:206 msgid "Node" msgstr "节点" -#: assets/models/user.py:185 +#: assets/models/user.py:189 msgid "Automatic managed" msgstr "托管密码" -#: assets/models/user.py:186 +#: assets/models/user.py:190 msgid "Manually input" msgstr "手动输入" -#: assets/models/user.py:190 +#: assets/models/user.py:194 msgid "Common user" msgstr "普通用户" -#: assets/models/user.py:193 +#: assets/models/user.py:197 msgid "Username same with user" msgstr "用户名与用户相同" -#: assets/models/user.py:196 assets/serializers/domain.py:28 -#: templates/_nav.html:39 xpack/plugins/change_auth_plan/models.py:56 +#: assets/models/user.py:200 assets/serializers/domain.py:28 +#: templates/_nav.html:39 xpack/plugins/change_auth_plan/models.py:52 msgid "Assets" msgstr "资产" -#: assets/models/user.py:200 templates/_nav.html:17 +#: assets/models/user.py:204 templates/_nav.html:17 #: users/views/profile/pubkey.py:37 msgid "Users" msgstr "用户管理" -#: assets/models/user.py:201 +#: assets/models/user.py:205 msgid "User groups" msgstr "用户组" -#: assets/models/user.py:205 +#: assets/models/user.py:209 msgid "Auto push" msgstr "自动推送" -#: assets/models/user.py:206 +#: assets/models/user.py:210 msgid "Sudo" msgstr "Sudo" -#: assets/models/user.py:207 +#: assets/models/user.py:211 msgid "Shell" msgstr "Shell" -#: assets/models/user.py:208 +#: assets/models/user.py:212 msgid "Login mode" msgstr "认证方式" -#: assets/models/user.py:210 +#: assets/models/user.py:214 msgid "SFTP Root" msgstr "SFTP根路径" -#: assets/models/user.py:211 authentication/models.py:95 +#: assets/models/user.py:215 authentication/models.py:94 msgid "Token" msgstr "" -#: assets/models/user.py:212 +#: assets/models/user.py:216 msgid "Home" msgstr "家目录" -#: assets/models/user.py:213 +#: assets/models/user.py:217 msgid "System groups" msgstr "用户组" @@ -823,6 +798,10 @@ msgstr "用户组" msgid "%(value)s is not an even number" msgstr "%(value)s is not an even number" +#: assets/serializers/account.py:31 assets/serializers/account.py:52 +msgid "System user display" +msgstr "系统用户名称" + #: assets/serializers/asset.py:20 msgid "Protocol format should {}/{}" msgstr "协议格式 {}/{}" @@ -839,11 +818,16 @@ msgstr "网域名称" msgid "Nodes name" msgstr "节点名称" -#: assets/serializers/asset.py:96 +#: assets/serializers/asset.py:97 msgid "Hardware info" msgstr "硬件信息" -#: assets/serializers/asset.py:98 +#: assets/serializers/asset.py:98 assets/serializers/system_user.py:218 +#: orgs/mixins/serializers.py:26 +msgid "Org name" +msgstr "用户名" + +#: assets/serializers/asset.py:99 msgid "Admin user display" msgstr "特权用户名称" @@ -887,7 +871,7 @@ msgstr "密钥指纹" msgid "Nodes amount" msgstr "节点数量" -#: assets/serializers/system_user.py:53 assets/serializers/system_user.py:201 +#: assets/serializers/system_user.py:53 assets/serializers/system_user.py:202 msgid "Login mode display" msgstr "认证方式名称" @@ -895,27 +879,31 @@ msgstr "认证方式名称" msgid "Ad domain" msgstr "Ad 网域" -#: assets/serializers/system_user.py:95 +#: assets/serializers/system_user.py:56 +msgid "Is asset protocol" +msgstr "" + +#: assets/serializers/system_user.py:96 msgid "Username same with user with protocol {} only allow 1" msgstr "用户名和用户相同的一种协议只允许存在一个" -#: assets/serializers/system_user.py:109 +#: assets/serializers/system_user.py:110 msgid "* Automatic login mode must fill in the username." msgstr "自动登录模式,必须填写用户名" -#: assets/serializers/system_user.py:123 +#: assets/serializers/system_user.py:124 msgid "Path should starts with /" msgstr "路径应该以 / 开头" -#: assets/serializers/system_user.py:148 +#: assets/serializers/system_user.py:149 msgid "Password or private key required" msgstr "密码或密钥密码需要一个" -#: assets/serializers/system_user.py:216 +#: assets/serializers/system_user.py:217 msgid "System user name" msgstr "系统用户名称" -#: assets/serializers/system_user.py:226 +#: assets/serializers/system_user.py:227 msgid "Asset hostname" msgstr "资产主机名" @@ -1069,7 +1057,7 @@ msgid "Symlink" msgstr "建立软链接" #: audits/models.py:37 audits/models.py:60 audits/models.py:76 -#: terminal/models/session.py:45 +#: terminal/models/session.py:45 terminal/models/sharing.py:76 msgid "Remote addr" msgstr "远端地址" @@ -1081,18 +1069,16 @@ msgstr "操作" msgid "Filename" msgstr "文件名" -#: audits/models.py:42 audits/models.py:101 +#: audits/models.py:42 audits/models.py:101 terminal/models/sharing.py:84 msgid "Success" msgstr "成功" -#: audits/models.py:43 ops/models/command.py:30 perms/models/base.py:53 +#: audits/models.py:43 ops/models/command.py:30 perms/models/base.py:49 #: terminal/models/session.py:52 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:43 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:74 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:40 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:78 -#: xpack/plugins/change_auth_plan/models.py:194 -#: xpack/plugins/change_auth_plan/models.py:340 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:53 +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:45 +#: xpack/plugins/change_auth_plan/models.py:177 +#: xpack/plugins/change_auth_plan/models.py:307 #: xpack/plugins/gathered_user/models.py:76 msgid "Date start" msgstr "开始日期" @@ -1158,18 +1144,19 @@ msgstr "用户代理" #: audits/models.py:110 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 #: authentication/templates/authentication/login_otp.html:6 -#: users/forms/profile.py:64 users/models/user.py:583 +#: users/forms/profile.py:64 users/models/user.py:626 #: users/serializers/profile.py:102 msgid "MFA" msgstr "多因子认证" -#: audits/models.py:111 xpack/plugins/change_auth_plan/models.py:336 -#: xpack/plugins/cloud/models.py:176 +#: audits/models.py:111 terminal/models/sharing.py:88 +#: xpack/plugins/change_auth_plan/models.py:303 +#: xpack/plugins/cloud/models.py:171 msgid "Reason" msgstr "原因" -#: audits/models.py:112 tickets/models/ticket.py:47 -#: xpack/plugins/cloud/models.py:172 xpack/plugins/cloud/models.py:221 +#: audits/models.py:112 tickets/models/ticket.py:57 +#: xpack/plugins/cloud/models.py:167 xpack/plugins/cloud/models.py:216 msgid "Status" msgstr "状态" @@ -1185,7 +1172,7 @@ msgstr "认证方式" msgid "Operate display" msgstr "操作名称" -#: audits/serializers.py:30 tickets/serializers/ticket/ticket.py:24 +#: audits/serializers.py:30 tickets/serializers/ticket/ticket.py:23 msgid "Status display" msgstr "状态名称" @@ -1203,7 +1190,7 @@ msgid "Hosts display" msgstr "主机名称" #: audits/serializers.py:89 ops/models/command.py:26 -#: xpack/plugins/cloud/models.py:170 +#: xpack/plugins/cloud/models.py:165 msgid "Result" msgstr "结果" @@ -1237,13 +1224,13 @@ msgstr "认证令牌" #: audits/signals_handler.py:66 #: authentication/templates/authentication/login.html:210 -#: notifications/backends/__init__.py:13 +#: notifications/backends/__init__.py:11 msgid "WeCom" msgstr "企业微信" #: audits/signals_handler.py:67 #: authentication/templates/authentication/login.html:215 -#: notifications/backends/__init__.py:14 +#: notifications/backends/__init__.py:12 msgid "DingTalk" msgstr "钉钉" @@ -1430,11 +1417,11 @@ msgstr "{ApplicationPermission} *添加了* {SystemUser}" msgid "{ApplicationPermission} *REMOVE* {SystemUser}" msgstr "{ApplicationPermission} *移除了* {SystemUser}" -#: authentication/api/connection_token.py:222 +#: authentication/api/connection_token.py:226 msgid "Invalid token" msgstr "无效的令牌" -#: authentication/api/mfa.py:64 +#: authentication/api/mfa.py:81 msgid "Code is invalid" msgstr "Code无效" @@ -1489,59 +1476,59 @@ msgstr "" msgid "Invalid token or cache refreshed." msgstr "" -#: authentication/errors.py:25 +#: authentication/errors.py:27 msgid "Username/password check failed" msgstr "用户名/密码 校验失败" -#: authentication/errors.py:26 +#: authentication/errors.py:28 msgid "Password decrypt failed" msgstr "密码解密失败" -#: authentication/errors.py:27 +#: authentication/errors.py:29 msgid "MFA failed" msgstr "多因子认证失败" -#: authentication/errors.py:28 +#: authentication/errors.py:30 msgid "MFA unset" msgstr "多因子认证没有设定" -#: authentication/errors.py:29 +#: authentication/errors.py:31 msgid "Username does not exist" msgstr "用户名不存在" -#: authentication/errors.py:30 +#: authentication/errors.py:32 msgid "Password expired" msgstr "密码已过期" -#: authentication/errors.py:31 +#: authentication/errors.py:33 msgid "Disabled or expired" msgstr "禁用或失效" -#: authentication/errors.py:32 +#: authentication/errors.py:34 msgid "This account is inactive." msgstr "此账户已禁用" -#: authentication/errors.py:33 +#: authentication/errors.py:35 msgid "This account is expired" msgstr "此账户已过期" -#: authentication/errors.py:34 +#: authentication/errors.py:36 msgid "Auth backend not match" msgstr "没有匹配到认证后端" -#: authentication/errors.py:35 +#: authentication/errors.py:37 msgid "ACL is not allowed" msgstr "ACL 不被允许" -#: authentication/errors.py:36 +#: authentication/errors.py:38 msgid "Only local users are allowed" msgstr "仅允许本地用户" -#: authentication/errors.py:46 +#: authentication/errors.py:48 msgid "No session found, check your cookie" msgstr "会话已变更,刷新页面" -#: authentication/errors.py:48 +#: authentication/errors.py:50 #, python-brace-format msgid "" "The username or password you entered is incorrect, please enter it again. " @@ -1551,62 +1538,78 @@ msgstr "" "您输入的用户名或密码不正确,请重新输入。 您还可以尝试 {times_try} 次(账号将" "被临时 锁定 {block_time} 分钟)" -#: authentication/errors.py:54 authentication/errors.py:58 +#: authentication/errors.py:56 authentication/errors.py:60 msgid "" "The account has been locked (please contact admin to unlock it or try again " "after {} minutes)" msgstr "账号已被锁定(请联系管理员解锁 或 {}分钟后重试)" -#: authentication/errors.py:62 -#, python-brace-format +#: authentication/errors.py:64 msgid "" -"MFA code invalid, or ntp sync server time, You can also try {times_try} " -"times (The account will be temporarily locked for {block_time} minutes)" +"One-time password invalid, or ntp sync server time, You can also try " +"{times_try} times (The account will be temporarily locked for {block_time} " +"minutes)" msgstr "" -"MFA验证码不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" +"虚拟MFA 不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" "临时 锁定 {block_time} 分钟)" -#: authentication/errors.py:67 +#: authentication/errors.py:69 +msgid "" +"SMS verify code invalid,You can also try {times_try} times (The account will " +"be temporarily locked for {block_time} minutes)" +msgstr "" +"短信验证码不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" +"临时 锁定 {block_time} 分钟)" + +#: authentication/errors.py:74 +msgid "" +"The MFA type({mfa_type}) is not supported, You can also try {times_try} times " +"(The account will be temporarily locked for {block_time} minutes)" +msgstr "" +"该({mfa_type}) MFA 类型不支持。 您还可以尝试 {times_try} 次(账号将被" +"临时 锁定 {block_time} 分钟)" + +#: authentication/errors.py:79 msgid "MFA required" msgstr "需要多因子认证" -#: authentication/errors.py:68 +#: authentication/errors.py:80 msgid "MFA not set, please set it first" msgstr "多因子认证没有设置,请先完成设置" -#: authentication/errors.py:69 +#: authentication/errors.py:81 msgid "Login confirm required" msgstr "需要登录复核" -#: authentication/errors.py:70 +#: authentication/errors.py:82 msgid "Wait login confirm ticket for accept" msgstr "等待登录复核处理" -#: authentication/errors.py:71 +#: authentication/errors.py:83 msgid "Login confirm ticket was {}" msgstr "登录复核 {}" -#: authentication/errors.py:235 +#: authentication/errors.py:260 msgid "IP is not allowed" msgstr "来源 IP 不被允许登录" -#: authentication/errors.py:268 +#: authentication/errors.py:293 msgid "SSO auth closed" msgstr "SSO 认证关闭了" -#: authentication/errors.py:273 authentication/mixins.py:277 +#: authentication/errors.py:298 authentication/mixins.py:319 msgid "Your password is too simple, please change it for security" msgstr "你的密码过于简单,为了安全,请修改" -#: authentication/errors.py:282 authentication/mixins.py:284 +#: authentication/errors.py:307 authentication/mixins.py:326 msgid "You should to change your password before login" msgstr "登录完成前,请先修改密码" -#: authentication/errors.py:291 authentication/mixins.py:291 +#: authentication/errors.py:316 authentication/mixins.py:333 msgid "Your password has expired, please reset before logging in" msgstr "您的密码已过期,先修改再登录" -#: authentication/errors.py:325 +#: authentication/errors.py:350 msgid "Your password is invalid" msgstr "您的密码无效" @@ -1614,12 +1617,20 @@ msgstr "您的密码无效" msgid "{} days auto login" msgstr "{} 天内自动登录" -#: authentication/forms.py:46 authentication/forms.py:59 -#: authentication/forms.py:61 users/forms/profile.py:27 +#: authentication/forms.py:46 +msgid "MFA Code" +msgstr "MFA 验证码" + +#: authentication/forms.py:47 +msgid "MFA type" +msgstr "MFA 类型" + +#: authentication/forms.py:60 authentication/forms.py:62 +#: users/forms/profile.py:27 msgid "MFA code" msgstr "多因子认证验证码" -#: authentication/mixins.py:267 +#: authentication/mixins.py:309 msgid "Please change your password" msgstr "请修改密码" @@ -1627,10 +1638,22 @@ msgstr "请修改密码" msgid "Private Token" msgstr "SSH密钥" -#: authentication/models.py:96 +#: authentication/models.py:95 msgid "Expired" msgstr "过期时间" +#: authentication/sms_verify_code.py:17 +msgid "The verification code has expired. Please resend it" +msgstr "验证码已过期,请重新发送" + +#: authentication/sms_verify_code.py:22 +msgid "The verification code is incorrect" +msgstr "验证码错误" + +#: authentication/sms_verify_code.py:27 +msgid "Please wait {} seconds before sending" +msgstr "请在 {} 秒后发送" + #: authentication/templates/authentication/_access_key_modal.html:6 msgid "API key list" msgstr "API Key列表" @@ -1649,8 +1672,9 @@ msgid "ID" msgstr "ID" #: authentication/templates/authentication/_access_key_modal.html:31 +#: settings/serializers/auth/radius.py:17 msgid "Secret" -msgstr "秘钥" +msgstr "密钥" #: authentication/templates/authentication/_access_key_modal.html:33 msgid "Date" @@ -1662,14 +1686,14 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: settings/serializers/settings.py:149 users/models/user.py:468 +#: settings/serializers/security.py:25 users/models/user.py:470 #: users/serializers/profile.py:99 #: users/templates/users/user_verify_mfa.html:32 msgid "Disable" msgstr "禁用" #: authentication/templates/authentication/_access_key_modal.html:67 -#: users/models/user.py:469 users/serializers/profile.py:100 +#: users/models/user.py:471 users/serializers/profile.py:100 msgid "Enable" msgstr "启用" @@ -1679,7 +1703,7 @@ msgstr "删除成功" #: authentication/templates/authentication/_access_key_modal.html:155 #: authentication/templates/authentication/_mfa_confirm_modal.html:53 -#: templates/_modal.html:22 tickets/const.py:20 +#: templates/_modal.html:22 tickets/const.py:36 msgid "Close" msgstr "关闭" @@ -1737,28 +1761,27 @@ msgid "CAS" msgstr "CAS" #: authentication/templates/authentication/login.html:220 -#: notifications/backends/__init__.py:16 +#: notifications/backends/__init__.py:14 msgid "FeiShu" msgstr "飞书" -#: authentication/templates/authentication/login_otp.html:17 -msgid "One-time password" -msgstr "一次性密码" +#: authentication/templates/authentication/login_otp.html:25 +msgid "Please enter the verification code" +msgstr "请输入验证码" -#: authentication/templates/authentication/login_otp.html:23 -#: users/templates/users/user_verify_mfa.html:13 -msgid "Open MFA Authenticator and enter the 6-bit dynamic code" -msgstr "请打开MFA验证器,输入6位动态码" +#: authentication/templates/authentication/login_otp.html:28 +msgid "Send verification code" +msgstr "发送验证码" -#: authentication/templates/authentication/login_otp.html:26 -#: users/templates/users/user_otp_check_password.html:15 +#: authentication/templates/authentication/login_otp.html:29 +#: users/templates/users/user_otp_check_password.html:16 #: users/templates/users/user_otp_enable_bind.html:24 #: users/templates/users/user_otp_enable_install_app.html:29 #: users/templates/users/user_verify_mfa.html:26 msgid "Next" msgstr "下一步" -#: authentication/templates/authentication/login_otp.html:29 +#: authentication/templates/authentication/login_otp.html:32 msgid "Can't provide security? Please contact the administrator!" msgstr "如果不能提供多因子认证验证码,请联系管理员!" @@ -1882,7 +1905,7 @@ msgstr "正在跳转到 {} 认证" msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: authentication/views/login.py:224 +#: authentication/views/login.py:215 msgid "" "Wait for {} confirm, You also can copy link to her/him
\n" " Don't close this page" @@ -1890,18 +1913,26 @@ msgstr "" "等待 {} 确认, 你也可以复制链接发给他/她
\n" " 不要关闭本页面" -#: authentication/views/login.py:229 +#: authentication/views/login.py:220 msgid "No ticket found" msgstr "没有发现工单" -#: authentication/views/login.py:261 +#: authentication/views/login.py:252 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:262 +#: authentication/views/login.py:253 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" +#: authentication/views/mfa.py:44 users/models/user.py:37 +msgid "One-time password" +msgstr "一次性密码" + +#: authentication/views/mfa.py:50 notifications/backends/__init__.py:15 +msgid "SMS" +msgstr "短信" + #: authentication/views/wecom.py:41 msgid "WeCom Error, Please contact your system administrator" msgstr "企业微信错误,请联系系统管理员" @@ -1949,6 +1980,10 @@ msgstr "%(name)s 创建成功" msgid "%(name)s was updated successfully" msgstr "%(name)s 更新成功" +#: common/db/encoder.py:17 +msgid "ugettext_lazy" +msgstr "" + #: common/db/models.py:71 msgid "Updated by" msgstr "更新人" @@ -2022,6 +2057,22 @@ msgstr "加密的字段" msgid "Network error, please contact system administrator" msgstr "网络错误,请联系系统管理员" +#: common/message/backends/sms/__init__.py:38 +msgid "Invalid SMS sign and template: {}" +msgstr "无效的短信签名和模版: {}" + +#: common/message/backends/sms/__init__.py:43 +msgid "Alibaba" +msgstr "阿里云" + +#: common/message/backends/sms/__init__.py:44 +msgid "Tencent" +msgstr "腾讯云" + +#: common/message/backends/sms/alibaba.py:56 +msgid "Signature does not match" +msgstr "签名不匹配" + #: common/message/backends/wecom/__init__.py:15 msgid "WeCom error, please contact system administrator" msgstr "企业微信错误,请联系系统管理员" @@ -2062,7 +2113,7 @@ msgstr "JumpServer 开源堡垒机" msgid "

Flower service unavailable, check it

" msgstr "Flower 服务不可用,请检查" -#: jumpserver/views/other.py:25 +#: jumpserver/views/other.py:26 msgid "" "
Luna is a separately deployed program, you need to deploy Luna, koko, " "configure nginx for url distribution,
If you see this page, " @@ -2071,11 +2122,11 @@ msgstr "" "
Luna是单独部署的一个程序,你需要部署luna,koko,
如果你看到了" "这个页面,证明你访问的不是nginx监听的端口,祝你好运
" -#: jumpserver/views/other.py:69 +#: jumpserver/views/other.py:70 msgid "Websocket server run on port: {}, you should proxy it on nginx" msgstr "Websocket 服务运行在端口: {}, 请检查nginx是否代理是否设置" -#: jumpserver/views/other.py:83 +#: jumpserver/views/other.py:84 msgid "" "
Koko is a separately deployed program, you need to deploy Koko, " "configure nginx for url distribution,
If you see this page, " @@ -2085,12 +2136,12 @@ msgstr "" "div>
如果你看到了这个页面,证明你访问的不是nginx监听的端口,祝你好运" -#: notifications/backends/__init__.py:12 users/forms/profile.py:101 -#: users/models/user.py:563 +#: notifications/backends/__init__.py:10 users/forms/profile.py:101 +#: users/models/user.py:606 msgid "Email" msgstr "邮件" -#: notifications/backends/__init__.py:15 +#: notifications/backends/__init__.py:13 msgid "Site message" msgstr "站内信" @@ -2111,15 +2162,16 @@ msgid "Cycle perform" msgstr "周期执行" #: ops/mixin.py:33 ops/mixin.py:90 ops/mixin.py:109 ops/mixin.py:150 +#: settings/serializers/auth/ldap.py:64 msgid "Regularly perform" msgstr "定期执行" #: ops/mixin.py:106 ops/mixin.py:147 -#: xpack/plugins/change_auth_plan/serializers.py:60 +#: xpack/plugins/change_auth_plan/serializers.py:55 msgid "Periodic perform" msgstr "定时执行" -#: ops/mixin.py:112 +#: ops/mixin.py:112 settings/serializers/auth/ldap.py:61 msgid "Interval" msgstr "间隔" @@ -2146,9 +2198,9 @@ msgstr "" "分 时 日 月 星期> (在线工" "具
注意: 如果同时设置了定期执行和周期执行,优先使用定期执行" -#: ops/mixin.py:162 -msgid "Tips: (Units: hour)" -msgstr "提示:(单位: 时)" +#: ops/mixin.py:162 settings/serializers/auth/ldap.py:61 +msgid "Unit: hour" +msgstr "单位: 时" #: ops/models/adhoc.py:35 msgid "Callback" @@ -2194,8 +2246,8 @@ msgstr "开始时间" msgid "End time" msgstr "完成时间" -#: ops/models/adhoc.py:246 xpack/plugins/change_auth_plan/models.py:197 -#: xpack/plugins/change_auth_plan/models.py:343 +#: ops/models/adhoc.py:246 xpack/plugins/change_auth_plan/models.py:180 +#: xpack/plugins/change_auth_plan/models.py:310 #: xpack/plugins/gathered_user/models.py:79 msgid "Time" msgstr "时间" @@ -2233,22 +2285,22 @@ msgstr "任务结束" msgid "Server performance" msgstr "监控告警" -#: ops/notifications.py:36 +#: ops/notifications.py:39 #, python-brace-format msgid "The terminal is offline: {name}" msgstr "终端已离线: {name}" -#: ops/notifications.py:42 +#: ops/notifications.py:45 #, python-brace-format msgid "[Disk] Disk used more than {max_threshold}%: => {value} ({name})" msgstr "[Disk] 硬盘使用率超过 {max_threshold}%: => {value} ({name})" -#: ops/notifications.py:49 +#: ops/notifications.py:52 #, python-brace-format msgid "[Memory] Memory used more than {max_threshold}%: => {value} ({name})" msgstr "[Memory] 内存使用率超过 {max_threshold}%: => {value} ({name})" -#: ops/notifications.py:56 +#: ops/notifications.py:59 #, python-brace-format msgid "[CPU] CPU load more than {max_threshold}: => {value} ({name})" msgstr "[CPU] CPU 使用率超过 {max_threshold}: => {value} ({name})" @@ -2277,6 +2329,12 @@ msgstr "当前组织 ({}) 不能被删除" msgid "The organization have resource ({}) cannot be deleted" msgstr "组织存在资源 ({}) 不能被删除" +#: orgs/mixins/models.py:46 orgs/mixins/serializers.py:25 orgs/models.py:37 +#: orgs/models.py:432 orgs/serializers.py:106 +#: tickets/serializers/ticket/ticket.py:77 +msgid "Organization" +msgstr "组织审计员" + #: orgs/models.py:17 users/const.py:12 msgid "Organization administrator" msgstr "组织管理员" @@ -2289,7 +2347,7 @@ msgstr "组织审计员" msgid "GLOBAL" msgstr "全局组织" -#: orgs/models.py:434 users/models/user.py:571 users/serializers/user.py:36 +#: orgs/models.py:434 users/models/user.py:614 users/serializers/user.py:36 #: users/templates/users/_select_user_modal.html:15 msgid "Role" msgstr "角色" @@ -2302,11 +2360,11 @@ msgstr "管理员正在修改授权,请稍等" msgid "The authorization cannot be revoked for the time being" msgstr "该授权暂时不能撤销" -#: perms/models/application_permission.py:27 users/models/user.py:177 +#: perms/models/application_permission.py:27 users/models/user.py:182 msgid "Application" msgstr "应用程序" -#: perms/models/asset_permission.py:37 settings/serializers/settings.py:118 +#: perms/models/asset_permission.py:37 settings/serializers/terminal.py:12 msgid "All" msgstr "全部" @@ -2353,8 +2411,8 @@ msgstr "未分组" msgid "Favorite" msgstr "收藏夹" -#: perms/models/base.py:51 templates/_nav.html:21 users/models/group.py:31 -#: users/models/user.py:567 users/templates/users/_select_user_modal.html:16 +#: perms/models/base.py:47 templates/_nav.html:21 users/models/group.py:31 +#: users/models/user.py:610 users/templates/users/_select_user_modal.html:16 #: users/templates/users/user_asset_permission.html:39 #: users/templates/users/user_asset_permission.html:67 #: users/templates/users/user_database_app_permission.html:38 @@ -2362,15 +2420,17 @@ msgstr "收藏夹" msgid "User group" msgstr "用户组" -#: perms/models/base.py:54 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:46 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:77 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:43 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:81 -#: users/models/user.py:599 +#: perms/models/base.py:50 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:56 +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:48 +#: users/models/user.py:642 msgid "Date expired" msgstr "失效日期" +#: perms/models/base.py:54 +msgid "From ticket" +msgstr "来自工单" + #: perms/serializers/application/permission.py:18 #: perms/serializers/application/permission.py:38 #: perms/serializers/asset/permission.py:42 @@ -2427,20 +2487,20 @@ msgstr "节点名称" msgid "System users display" msgstr "系统用户名称" -#: settings/api/common.py:25 -msgid "Test mail sent to {}, please check" -msgstr "邮件已经发送{}, 请检查" +#: settings/api/alibaba_sms.py:30 settings/api/tencent_sms.py:34 +msgid "test_phone is required" +msgstr "测试手机号 该字段是必填项。" -#: settings/api/common.py:100 xpack/plugins/interface/api.py:18 -#: xpack/plugins/interface/models.py:36 -msgid "Welcome to the JumpServer open source Bastion Host" -msgstr "欢迎使用JumpServer开源堡垒机" - -#: settings/api/dingtalk.py:36 settings/api/feishu.py:35 +#: settings/api/alibaba_sms.py:52 settings/api/dingtalk.py:36 +#: settings/api/feishu.py:35 settings/api/tencent_sms.py:57 #: settings/api/wecom.py:36 msgid "Test success" msgstr "测试成功" +#: settings/api/email.py:21 +msgid "Test mail sent to {}, please check" +msgstr "邮件已经发送{}, 请检查" + #: settings/api/ldap.py:194 msgid "Get ldap users is None" msgstr "获取 LDAP 用户为 None" @@ -2449,170 +2509,125 @@ msgstr "获取 LDAP 用户为 None" msgid "Imported {} users successfully (Organization: {})" msgstr "成功导入 {} 个用户 ( 组织: {} )" +#: settings/api/public.py:41 xpack/plugins/interface/api.py:18 +#: xpack/plugins/interface/models.py:36 +msgid "Welcome to the JumpServer open source Bastion Host" +msgstr "欢迎使用JumpServer开源堡垒机" + #: settings/models.py:123 users/templates/users/reset_password.html:29 msgid "Setting" msgstr "设置" -#: settings/serializers/settings.py:16 -msgid "Site url" -msgstr "当前站点URL" +#: settings/serializers/auth/base.py:10 +msgid "CAS Auth" +msgstr "CAS 认证" -#: settings/serializers/settings.py:17 -msgid "eg: http://dev.jumpserver.org:8080" -msgstr "如: http://dev.jumpserver.org:8080" +#: settings/serializers/auth/base.py:11 +msgid "OPENID Auth" +msgstr "OIDC 认证" -#: settings/serializers/settings.py:21 -msgid "User guide url" -msgstr "用户向导URL" +#: settings/serializers/auth/base.py:12 +msgid "RADIUS Auth" +msgstr "RADIUS 认证" -#: settings/serializers/settings.py:22 -msgid "User first login update profile done redirect to it" -msgstr "用户第一次登录,修改profile后重定向到地址, 可以是 wiki 或 其他说明文档" +#: settings/serializers/auth/base.py:13 +msgid "DingTalk Auth" +msgstr "钉钉 认证" -#: settings/serializers/settings.py:25 +#: settings/serializers/auth/base.py:14 +msgid "FeiShu Auth" +msgstr "飞书 认证" + +#: settings/serializers/auth/base.py:15 +msgid "WeCom Auth" +msgstr "企业微信 认证" + +#: settings/serializers/auth/base.py:16 +msgid "SSO Auth" +msgstr "SSO Token 认证" + +#: settings/serializers/auth/base.py:18 settings/serializers/basic.py:15 msgid "Forgot password url" -msgstr "忘记密码URL" +msgstr "忘记密码 URL" -#: settings/serializers/settings.py:26 -msgid "" -"The forgot password url on login page, If you use ldap or cas external " -"authentication, you can set it" -msgstr "" -"登录页面忘记密码URL, 如果使用了 LDAP, OPENID 等外部认证系统,可以自定义用户重" -"置密码访问的地址" +#: settings/serializers/auth/base.py:21 +msgid "Health check token" +msgstr "健康检查 Token" -#: settings/serializers/settings.py:30 -msgid "Global organization name" -msgstr "全局组织名" +#: settings/serializers/auth/base.py:24 +msgid "Enable login redirect msg" +msgstr "启用登录跳转提示" -#: settings/serializers/settings.py:31 -msgid "The name of global organization to display" -msgstr "全局组织的显示名称,默认为 全局组织" +#: settings/serializers/auth/cas.py:11 +msgid "Enable CAS Auth" +msgstr "启用 CAS 认证" -#: settings/serializers/settings.py:38 -msgid "SMTP host" -msgstr "SMTP 主机" +#: settings/serializers/auth/cas.py:12 settings/serializers/auth/oidc.py:32 +msgid "Server url" +msgstr "服务端地址" -#: settings/serializers/settings.py:39 -msgid "SMTP port" -msgstr "SMTP 端口" +#: settings/serializers/auth/cas.py:13 +msgid "Logout completely" +msgstr "同步注销" -#: settings/serializers/settings.py:40 -msgid "SMTP account" -msgstr "SMTP 账号" +#: settings/serializers/auth/cas.py:15 +msgid "Username attr" +msgstr "用户名属性" -#: settings/serializers/settings.py:42 -msgid "SMTP password" -msgstr "SMTP 密码" +#: settings/serializers/auth/cas.py:16 +msgid "Enable attributes map" +msgstr "启用属性映射" -#: settings/serializers/settings.py:43 -msgid "Tips: Some provider use token except password" -msgstr "提示:一些邮件提供商需要输入的是授权码" +#: settings/serializers/auth/cas.py:17 +msgid "Rename attr" +msgstr "映射属性" -#: settings/serializers/settings.py:46 -msgid "Send user" -msgstr "发件人" +#: settings/serializers/auth/cas.py:18 +msgid "Create user if not" +msgstr "创建用户(如果不存在)" -#: settings/serializers/settings.py:47 -msgid "Tips: Send mail account, default SMTP account as the send account" -msgstr "提示:发送邮件账号,默认使用 SMTP 账号作为发送账号" +#: settings/serializers/auth/dingtalk.py:11 +msgid "Enable DingTalk Auth" +msgstr "启用钉钉认证" -#: settings/serializers/settings.py:50 -msgid "Test recipient" -msgstr "测试收件人" +#: settings/serializers/auth/feishu.py:10 +msgid "Enable FeiShu Auth" +msgstr "启用飞书认证" -#: settings/serializers/settings.py:51 -msgid "Tips: Used only as a test mail recipient" -msgstr "提示:仅用来作为测试邮件收件人" - -#: settings/serializers/settings.py:54 -msgid "Use SSL" -msgstr "使用 SSL" - -#: settings/serializers/settings.py:55 -msgid "If SMTP port is 465, may be select" -msgstr "如果SMTP端口是465,通常需要启用 SSL" - -#: settings/serializers/settings.py:58 -msgid "Use TLS" -msgstr "使用 TLS" - -#: settings/serializers/settings.py:59 -msgid "If SMTP port is 587, may be select" -msgstr "如果SMTP端口是587,通常需要启用 TLS" - -#: settings/serializers/settings.py:62 -msgid "Subject prefix" -msgstr "主题前缀" - -#: settings/serializers/settings.py:69 -msgid "Create user email subject" -msgstr "邮件主题" - -#: settings/serializers/settings.py:70 -msgid "" -"Tips: When creating a user, send the subject of the email (eg:Create account " -"successfully)" -msgstr "提示: 创建用户时,发送设置密码邮件的主题 (例如: 创建用户成功)" - -#: settings/serializers/settings.py:74 -msgid "Create user honorific" -msgstr "邮件的敬语" - -#: settings/serializers/settings.py:75 -msgid "Tips: When creating a user, send the honorific of the email (eg:Hello)" -msgstr "提示: 创建用户时,发送设置密码邮件的敬语 (例如: 您好)" - -#: settings/serializers/settings.py:79 -msgid "Create user email content" -msgstr "邮件的内容" - -#: settings/serializers/settings.py:80 -msgid "Tips:When creating a user, send the content of the email" -msgstr "提示: 创建用户时,发送设置密码邮件的内容" - -#: settings/serializers/settings.py:83 -msgid "Signature" -msgstr "署名" - -#: settings/serializers/settings.py:84 -msgid "Tips: Email signature (eg:jumpserver)" -msgstr "邮件署名 (如:jumpserver)" - -#: settings/serializers/settings.py:92 +#: settings/serializers/auth/ldap.py:39 msgid "LDAP server" msgstr "LDAP 地址" -#: settings/serializers/settings.py:92 +#: settings/serializers/auth/ldap.py:40 msgid "eg: ldap://localhost:389" msgstr "如: ldap://localhost:389" -#: settings/serializers/settings.py:94 +#: settings/serializers/auth/ldap.py:42 msgid "Bind DN" msgstr "绑定 DN" -#: settings/serializers/settings.py:97 +#: settings/serializers/auth/ldap.py:46 msgid "User OU" msgstr "用户 OU" -#: settings/serializers/settings.py:98 +#: settings/serializers/auth/ldap.py:47 msgid "Use | split multi OUs" msgstr "多个 OU 使用 | 分割" -#: settings/serializers/settings.py:101 +#: settings/serializers/auth/ldap.py:50 msgid "User search filter" msgstr "用户过滤器" -#: settings/serializers/settings.py:102 +#: settings/serializers/auth/ldap.py:51 #, python-format msgid "Choice may be (cn|uid|sAMAccountName)=%(user)s)" msgstr "可能的选项是(cn或uid或sAMAccountName=%(user)s)" -#: settings/serializers/settings.py:105 +#: settings/serializers/auth/ldap.py:54 msgid "User attr map" msgstr "用户属性映射" -#: settings/serializers/settings.py:106 +#: settings/serializers/auth/ldap.py:55 msgid "" "User attr map present how to map LDAP user attr to jumpserver, username,name," "email is jumpserver attr" @@ -2620,23 +2635,526 @@ msgstr "" "用户属性映射代表怎样将LDAP中用户属性映射到jumpserver用户上,username, name," "email 是jumpserver的用户需要属性" -#: settings/serializers/settings.py:108 +#: settings/serializers/auth/ldap.py:58 xpack/plugins/cloud/serializers.py:207 +#: xpack/plugins/gathered_user/serializers.py:20 +msgid "Periodic display" +msgstr "定时执行" + +#: settings/serializers/auth/ldap.py:66 +msgid "Connect timeout" +msgstr "连接超时时间" + +#: settings/serializers/auth/ldap.py:67 +msgid "Search paged size" +msgstr "搜索分页数量" + +#: settings/serializers/auth/ldap.py:69 msgid "Enable LDAP auth" msgstr "启用 LDAP 认证" -#: settings/serializers/settings.py:119 +#: settings/serializers/auth/oidc.py:12 +msgid "Base site url" +msgstr "JumpServer 地址" + +#: settings/serializers/auth/oidc.py:15 +msgid "Client Id" +msgstr "客户端 ID" + +#: settings/serializers/auth/oidc.py:18 xpack/plugins/cloud/serializers.py:33 +msgid "Client Secret" +msgstr "客户端密钥" + +#: settings/serializers/auth/oidc.py:20 +msgid "Share session" +msgstr "共享会话" + +#: settings/serializers/auth/oidc.py:22 +msgid "Ignore ssl verification" +msgstr "忽略 SSL 证书验证" + +#: settings/serializers/auth/oidc.py:29 +msgid "Use Keycloak" +msgstr "使用 Keycloak" + +#: settings/serializers/auth/oidc.py:35 +msgid "Realm name" +msgstr "域" + +#: settings/serializers/auth/oidc.py:41 +msgid "Enable OPENID Auth" +msgstr "启用 OIDC 认证" + +#: settings/serializers/auth/oidc.py:43 +msgid "Provider endpoint" +msgstr "端点地址" + +#: settings/serializers/auth/oidc.py:46 +msgid "Provider auth endpoint" +msgstr "授权端点地址" + +#: settings/serializers/auth/oidc.py:49 +msgid "Provider token endpoint" +msgstr "token 端点地址" + +#: settings/serializers/auth/oidc.py:52 +msgid "Provider jwks endpoint" +msgstr "jwks 端点地址" + +#: settings/serializers/auth/oidc.py:55 +msgid "Provider userinfo endpoint" +msgstr "用户信息端点地址" + +#: settings/serializers/auth/oidc.py:58 +msgid "Provider end session endpoint" +msgstr "注销会话端点地址" + +#: settings/serializers/auth/oidc.py:61 +msgid "Provider sign alg" +msgstr "签名算法" + +#: settings/serializers/auth/oidc.py:64 +msgid "Provider sign key" +msgstr "签名 Key" + +#: settings/serializers/auth/oidc.py:66 +msgid "Scopes" +msgstr "连接范围" + +#: settings/serializers/auth/oidc.py:68 +msgid "Id token max age" +msgstr "令牌有效时间" + +#: settings/serializers/auth/oidc.py:71 +msgid "Id token include claims" +msgstr "声明" + +#: settings/serializers/auth/oidc.py:73 +msgid "Use state" +msgstr "使用状态" + +#: settings/serializers/auth/oidc.py:74 +msgid "Use nonce" +msgstr "临时使用" + +#: settings/serializers/auth/oidc.py:76 +msgid "Always update user" +msgstr "总是更新用户信息" + +#: settings/serializers/auth/radius.py:13 +msgid "Enable RADIUS Auth" +msgstr "启用 RADIUS 认证" + +#: settings/serializers/auth/radius.py:19 +msgid "OTP in radius" +msgstr "使用 Radius OTP" + +#: settings/serializers/auth/sms.py:10 +msgid "Enable SMS" +msgstr "启用 SMS" + +#: settings/serializers/auth/sms.py:11 +msgid "Test phone" +msgstr "测试手机号" + +#: settings/serializers/auth/sms.py:25 settings/serializers/auth/sms.py:43 +msgid "Signatures and Templates" +msgstr "签名和模版" + +#: settings/serializers/auth/sms.py:25 settings/serializers/auth/sms.py:43 +msgid "" +"\n" +" Filling in JSON Data: \n" +" {\n" +" \"verification_code\": {\n" +" \"sign_name\": \"\", \n" +" \"template_code\": \"\"\n" +" }\n" +" }\n" +" " +msgstr "" + +#: settings/serializers/auth/sso.py:12 +msgid "Enable SSO auth" +msgstr "启用 SSO Token 认证" + +#: settings/serializers/auth/sso.py:13 +msgid "Other service can using SSO token login to JumpServer without password" +msgstr "其它系统可以使用 SSO Token 对接 JumpServer, 免去登录的过程" + +#: settings/serializers/auth/sso.py:16 +msgid "SSO auth key TTL" +msgstr "Token 有效期" + +#: settings/serializers/auth/sso.py:16 settings/serializers/security.py:72 +msgid "Unit: second" +msgstr "单位: 秒" + +#: settings/serializers/auth/wecom.py:11 +msgid "Enable WeCom Auth" +msgstr "启用企业微信认证" + +#: settings/serializers/basic.py:7 +msgid "Site url" +msgstr "当前站点URL" + +#: settings/serializers/basic.py:8 +msgid "eg: http://dev.jumpserver.org:8080" +msgstr "如: http://dev.jumpserver.org:8080" + +#: settings/serializers/basic.py:11 +msgid "User guide url" +msgstr "用户向导URL" + +#: settings/serializers/basic.py:12 +msgid "User first login update profile done redirect to it" +msgstr "用户第一次登录,修改profile后重定向到地址, 可以是 wiki 或 其他说明文档" + +#: settings/serializers/basic.py:16 +msgid "" +"The forgot password url on login page, If you use ldap or cas external " +"authentication, you can set it" +msgstr "" +"登录页面忘记密码URL, 如果使用了 LDAP, OPENID 等外部认证系统,可以自定义用户重" +"置密码访问的地址" + +#: settings/serializers/basic.py:20 +msgid "Global organization name" +msgstr "全局组织名" + +#: settings/serializers/basic.py:21 +msgid "The name of global organization to display" +msgstr "全局组织的显示名称,默认为 全局组织" + +#: settings/serializers/cleaning.py:9 +msgid "Login log keep days" +msgstr "登录日志" + +#: settings/serializers/cleaning.py:9 settings/serializers/cleaning.py:12 +#: settings/serializers/cleaning.py:15 settings/serializers/cleaning.py:18 +#: settings/serializers/cleaning.py:21 +msgid "Unit: day" +msgstr "单位: 天" + +#: settings/serializers/cleaning.py:12 +msgid "Task log keep days" +msgstr "任务日志" + +#: settings/serializers/cleaning.py:15 +msgid "Operate log keep days" +msgstr "操作日志" + +#: settings/serializers/cleaning.py:18 +msgid "FTP log keep days" +msgstr "上传下载" + +#: settings/serializers/cleaning.py:21 +msgid "Cloud sync record keep days" +msgstr "云同步记录" + +#: settings/serializers/email.py:24 +msgid "SMTP host" +msgstr "SMTP 主机" + +#: settings/serializers/email.py:25 +msgid "SMTP port" +msgstr "SMTP 端口" + +#: settings/serializers/email.py:26 +msgid "SMTP account" +msgstr "SMTP 账号" + +#: settings/serializers/email.py:28 +msgid "SMTP password" +msgstr "SMTP 密码" + +#: settings/serializers/email.py:29 +msgid "Tips: Some provider use token except password" +msgstr "提示:一些邮件提供商需要输入的是授权码" + +#: settings/serializers/email.py:32 +msgid "Send user" +msgstr "发件人" + +#: settings/serializers/email.py:33 +msgid "Tips: Send mail account, default SMTP account as the send account" +msgstr "提示:发送邮件账号,默认使用 SMTP 账号作为发送账号" + +#: settings/serializers/email.py:36 +msgid "Test recipient" +msgstr "测试收件人" + +#: settings/serializers/email.py:37 +msgid "Tips: Used only as a test mail recipient" +msgstr "提示:仅用来作为测试邮件收件人" + +#: settings/serializers/email.py:40 +msgid "Use SSL" +msgstr "使用 SSL" + +#: settings/serializers/email.py:41 +msgid "If SMTP port is 465, may be select" +msgstr "如果SMTP端口是465,通常需要启用 SSL" + +#: settings/serializers/email.py:44 +msgid "Use TLS" +msgstr "使用 TLS" + +#: settings/serializers/email.py:45 +msgid "If SMTP port is 587, may be select" +msgstr "如果SMTP端口是587,通常需要启用 TLS" + +#: settings/serializers/email.py:48 +msgid "Subject prefix" +msgstr "主题前缀" + +#: settings/serializers/email.py:55 +msgid "Create user email subject" +msgstr "邮件主题" + +#: settings/serializers/email.py:56 +msgid "" +"Tips: When creating a user, send the subject of the email (eg:Create account " +"successfully)" +msgstr "提示: 创建用户时,发送设置密码邮件的主题 (例如: 创建用户成功)" + +#: settings/serializers/email.py:60 +msgid "Create user honorific" +msgstr "邮件的敬语" + +#: settings/serializers/email.py:61 +msgid "Tips: When creating a user, send the honorific of the email (eg:Hello)" +msgstr "提示: 创建用户时,发送设置密码邮件的敬语 (例如: 您好)" + +#: settings/serializers/email.py:65 +msgid "Create user email content" +msgstr "邮件的内容" + +#: settings/serializers/email.py:66 +msgid "Tips:When creating a user, send the content of the email" +msgstr "提示: 创建用户时,发送设置密码邮件的内容" + +#: settings/serializers/email.py:69 +msgid "Signature" +msgstr "署名" + +#: settings/serializers/email.py:70 +msgid "Tips: Email signature (eg:jumpserver)" +msgstr "邮件署名 (如:jumpserver)" + +#: settings/serializers/other.py:9 +msgid "Email suffix" +msgstr "邮件后缀" + +#: settings/serializers/other.py:10 +msgid "" +"This is used by default if no email is returned during SSO authentication" +msgstr "SSO认证时,如果没有返回邮件地址,将使用该后缀" + +#: settings/serializers/other.py:12 +msgid "Enable tickets" +msgstr "启用工单系统" + +#: settings/serializers/other.py:15 +msgid "OTP issuer name" +msgstr "OTP 扫描后的名称" + +#: settings/serializers/other.py:17 +msgid "OTP valid window" +msgstr "OTP 延迟有效次数" + +#: settings/serializers/other.py:19 +msgid "Enable period task" +msgstr "启用周期任务" + +#: settings/serializers/other.py:21 +msgid "Ansible windows default shell" +msgstr "Ansible windows shell" + +#: settings/serializers/other.py:25 +msgid "Perm single to ungroup node" +msgstr "直接授权资产放在未分组节点" + +#: settings/serializers/security.py:8 +msgid "Password minimum length" +msgstr "密码最小长度" + +#: settings/serializers/security.py:12 +msgid "Admin user password minimum length" +msgstr "管理员密码最小长度" + +#: settings/serializers/security.py:15 +msgid "Must contain capital" +msgstr "必须包含大写字符" + +#: settings/serializers/security.py:17 +msgid "Must contain lowercase" +msgstr "必须包含小写字符" + +#: settings/serializers/security.py:18 +msgid "Must contain numeric" +msgstr "必须包含数字" + +#: settings/serializers/security.py:19 +msgid "Must contain special" +msgstr "必须包含特殊字符" + +#: settings/serializers/security.py:26 +msgid "All users" +msgstr "所有用户" + +#: settings/serializers/security.py:27 +msgid "Only admin users" +msgstr "仅管理员" + +#: settings/serializers/security.py:29 +msgid "Global MFA auth" +msgstr "全局启用 MFA 认证" + +#: settings/serializers/security.py:33 +msgid "Limit the number of login failures" +msgstr "限制登录失败次数" + +#: settings/serializers/security.py:37 +msgid "Block logon interval" +msgstr "禁止登录时间间隔" + +#: settings/serializers/security.py:39 +msgid "" +"Unit: minute, If the user has failed to log in for a limited number of " +"times, no login is allowed during this time interval." +msgstr "单位:分, 当用户登录失败次数达到限制后,那么在此时间间隔内禁止登录" + +#: settings/serializers/security.py:45 +msgid "User password expiration" +msgstr "用户密码过期时间" + +#: settings/serializers/security.py:47 +msgid "" +"Unit: day, If the user does not update the password during the time, the " +"user password will expire failure;The password expiration reminder mail will " +"be automatic sent to the user by system within 5 days (daily) before the " +"password expires" +msgstr "" +"单位:天, 如果用户在此期间没有更新密码,用户密码将过期失效; 密码过期提醒邮件" +"将在密码过期前5天内由系统(每天)自动发送给用户" + +#: settings/serializers/security.py:54 +msgid "Number of repeated historical passwords" +msgstr "不能设置近几次密码" + +#: settings/serializers/security.py:56 +msgid "" +"Tip: When the user resets the password, it cannot be the previous n " +"historical passwords of the user" +msgstr "提示:用户重置密码时,不能为该用户前几次使用过的密码" + +#: settings/serializers/security.py:61 +msgid "Only single device login" +msgstr "仅一台设备登录" + +#: settings/serializers/security.py:62 +msgid "Next device login, pre login will be logout" +msgstr "下个设备登录,上次登录会被顶掉" + +#: settings/serializers/security.py:65 +msgid "Only exist user login" +msgstr "仅已存在用户登录" + +#: settings/serializers/security.py:66 settings/serializers/security.py:70 +msgid "If enable, CAS、OIDC auth will be failed, if user not exist yet" +msgstr "开启后,如果系统中不存在该用户,CAS、OIDC 登录将会失败" + +#: settings/serializers/security.py:69 +msgid "Only from source login" +msgstr "仅从用户来源登录" + +#: settings/serializers/security.py:72 +msgid "MFA verify TTL" +msgstr "MFA 校验有效期" + +#: settings/serializers/security.py:75 +msgid "Enable Login captcha" +msgstr "启用登录验证码" + +#: settings/serializers/security.py:81 +msgid "Enable terminal register" +msgstr "终端注册" + +#: settings/serializers/security.py:82 +msgid "" +"Allow terminal register, after all terminal setup, you should disable this " +"for security" +msgstr "是否允许终端注册,当所有终端启动后,为了安全应该关闭" + +#: settings/serializers/security.py:85 +msgid "Replay watermark" +msgstr "录像水印" + +#: settings/serializers/security.py:86 +msgid "Enabled, the session replay contains watermark information" +msgstr "启用后,会话录像将包含水印信息" + +#: settings/serializers/security.py:90 +msgid "Connection max idle time" +msgstr "连接最大空闲时间" + +#: settings/serializers/security.py:91 +msgid "If idle time more than it, disconnect connection Unit: minute" +msgstr "提示:如果超过该配置没有操作,连接会被断开 (单位:分)" + +#: settings/serializers/security.py:94 +msgid "Remember manual auth" +msgstr "保存手动输入密码" + +#: settings/serializers/security.py:97 +msgid "Enable change auth secure mode" +msgstr "启用改密安全模式" + +#: settings/serializers/security.py:100 +msgid "Insecure command alert" +msgstr "危险命令告警" + +#: settings/serializers/security.py:103 +msgid "Email recipient" +msgstr "邮件收件人" + +#: settings/serializers/security.py:104 +msgid "Multiple user using , split" +msgstr "多个用户,使用 , 分割" + +#: settings/serializers/security.py:107 +msgid "Batch command execution" +msgstr "批量命令执行" + +#: settings/serializers/security.py:108 +msgid "Allow user run batch command or not using ansible" +msgstr "是否允许用户使用 ansible 执行批量命令" + +#: settings/serializers/security.py:111 +msgid "Session share" +msgstr "会话分享" + +#: settings/serializers/security.py:112 +msgid "Enabled, Allows user active session to be shared with other users" +msgstr "开启后允许用户分享已连接的资产会话给它人,协同工作" + +#: settings/serializers/sms.py:7 +msgid "Label" +msgstr "标签" + +#: settings/serializers/terminal.py:13 msgid "Auto" msgstr "自动" -#: settings/serializers/settings.py:125 +#: settings/serializers/terminal.py:19 msgid "Password auth" msgstr "密码认证" -#: settings/serializers/settings.py:127 +#: settings/serializers/terminal.py:21 msgid "Public key auth" msgstr "密钥认证" -#: settings/serializers/settings.py:128 +#: settings/serializers/terminal.py:22 msgid "" "Tips: If use other auth method, like AD/LDAP, you should disable this to " "avoid being able to log in after deleting" @@ -2644,170 +3162,47 @@ msgstr "" "提示:如果你使用其它认证方式,如 AD/LDAP,你应该禁用此项,以避免第三方系统删" "除后,还可以登录" -#: settings/serializers/settings.py:131 +#: settings/serializers/terminal.py:25 msgid "List sort by" msgstr "资产列表排序" -#: settings/serializers/settings.py:132 +#: settings/serializers/terminal.py:27 msgid "List page size" msgstr "资产列表每页数量" -#: settings/serializers/settings.py:134 +#: settings/serializers/terminal.py:29 msgid "Session keep duration" msgstr "会话日志保存时间" -#: settings/serializers/settings.py:135 +#: settings/serializers/terminal.py:30 msgid "" -"Units: days, Session, record, command will be delete if more than duration, " +"Unit: days, Session, record, command will be delete if more than duration, " "only in database" msgstr "" "单位:天。 会话、录像、命令记录超过该时长将会被删除(仅影响数据库存储, oss等不" "受影响)" -#: settings/serializers/settings.py:137 +#: settings/serializers/terminal.py:33 msgid "Telnet login regex" msgstr "Telnet 成功正则表达式" -#: settings/serializers/settings.py:139 +#: settings/serializers/terminal.py:34 +msgid "" +"The login success message varies with devices. if you cannot log in to the " +"device through Telnet, set this parameter" +msgstr "不同设备登录成功提示不一样,所以如果 telnet 不能正常登录,可以这里设置" + +#: settings/serializers/terminal.py:38 msgid "RDP address" msgstr "RDP 地址" -#: settings/serializers/settings.py:142 +#: settings/serializers/terminal.py:39 msgid "RDP visit address, eg: dev.jumpserver.org:3389" msgstr "RDP 访问地址, 如: dev.jumpserver.org:3389" -#: settings/serializers/settings.py:150 -msgid "All users" -msgstr "所有用户" - -#: settings/serializers/settings.py:151 -msgid "Only admin users" -msgstr "仅管理员" - -#: settings/serializers/settings.py:153 -msgid "Global MFA auth" -msgstr "全局启用 MFA 认证" - -#: settings/serializers/settings.py:156 -msgid "Batch command execution" -msgstr "批量命令执行" - -#: settings/serializers/settings.py:157 -msgid "Allow user run batch command or not using ansible" -msgstr "是否允许用户使用 ansible 执行批量命令" - -#: settings/serializers/settings.py:160 -msgid "Enable terminal register" -msgstr "终端注册" - -#: settings/serializers/settings.py:161 -msgid "" -"Allow terminal register, after all terminal setup, you should disable this " -"for security" -msgstr "是否允许终端注册,当所有终端启动后,为了安全应该关闭" - -#: settings/serializers/settings.py:164 -msgid "Replay watermark" -msgstr "录像水印" - -#: settings/serializers/settings.py:165 -msgid "Enabled, the session replay contains watermark information" -msgstr "启用后,会话录像将包含水印信息" - -#: settings/serializers/settings.py:169 -msgid "Limit the number of login failures" -msgstr "限制登录失败次数" - -#: settings/serializers/settings.py:173 -msgid "Block logon interval" -msgstr "禁止登录时间间隔" - -#: settings/serializers/settings.py:174 -msgid "" -"Tip: (unit/minute) if the user has failed to log in for a limited number of " -"times, no login is allowed during this time interval." -msgstr "" -"提示:(单位:分)当用户登录失败次数达到限制后,那么在此时间间隔内禁止登录" - -#: settings/serializers/settings.py:178 -msgid "Connection max idle time" -msgstr "连接最大空闲时间" - -#: settings/serializers/settings.py:179 -msgid "If idle time more than it, disconnect connection Unit: minute" -msgstr "提示:如果超过该配置没有操作,连接会被断开 (单位:分)" - -#: settings/serializers/settings.py:183 -msgid "User password expiration" -msgstr "用户密码过期时间" - -#: settings/serializers/settings.py:184 -msgid "" -"Tip: (unit: day) If the user does not update the password during the time, " -"the user password will expire failure;The password expiration reminder mail " -"will be automatic sent to the user by system within 5 days (daily) before " -"the password expires" -msgstr "" -"提示:(单位:天)如果用户在此期间没有更新密码,用户密码将过期失效; 密码过期" -"提醒邮件将在密码过期前5天内由系统(每天)自动发送给用户" - -#: settings/serializers/settings.py:188 -msgid "Number of repeated historical passwords" -msgstr "不能设置近几次密码" - -#: settings/serializers/settings.py:189 -msgid "" -"Tip: When the user resets the password, it cannot be the previous n " -"historical passwords of the user" -msgstr "提示:用户重置密码时,不能为该用户前几次使用过的密码" - -#: settings/serializers/settings.py:193 -msgid "Password minimum length" -msgstr "密码最小长度" - -#: settings/serializers/settings.py:197 -msgid "Admin user password minimum length" -msgstr "管理员密码最小长度" - -#: settings/serializers/settings.py:200 -msgid "Must contain capital" -msgstr "必须包含大写字符" - -#: settings/serializers/settings.py:202 -msgid "Must contain lowercase" -msgstr "必须包含小写字符" - -#: settings/serializers/settings.py:203 -msgid "Must contain numeric" -msgstr "必须包含数字" - -#: settings/serializers/settings.py:204 -msgid "Must contain special" -msgstr "必须包含特殊字符" - -#: settings/serializers/settings.py:205 -msgid "Insecure command alert" -msgstr "危险命令告警" - -#: settings/serializers/settings.py:207 -msgid "Email recipient" -msgstr "邮件收件人" - -#: settings/serializers/settings.py:208 -msgid "Multiple user using , split" -msgstr "多个用户,使用 , 分割" - -#: settings/serializers/settings.py:216 -msgid "Enable WeCom Auth" -msgstr "启用企业微信认证" - -#: settings/serializers/settings.py:223 -msgid "Enable DingTalk Auth" -msgstr "启用钉钉认证" - -#: settings/serializers/settings.py:229 -msgid "Enable FeiShu Auth" -msgstr "启用飞书认证" +#: settings/serializers/terminal.py:42 +msgid "Enable XRDP" +msgstr "启用 XRDP 服务" #: settings/utils/ldap.py:412 msgid "ldap:// or ldaps:// protocol is used." @@ -3375,6 +3770,10 @@ msgstr "用户不存在: {}" msgid "User does not have permission" msgstr "用户没有权限" +#: terminal/api/sharing.py:28 +msgid "Secure session sharing settings is disabled" +msgstr "未开启会话共享" + #: terminal/api/storage.py:30 msgid "Deleting the default storage is not allowed" msgstr "不允许删除默认存储配置" @@ -3428,7 +3827,8 @@ msgstr "输入" msgid "Output" msgstr "输出" -#: terminal/backends/command/models.py:23 +#: terminal/backends/command/models.py:23 terminal/models/sharing.py:15 +#: terminal/models/sharing.py:58 msgid "Session" msgstr "会话" @@ -3474,7 +3874,7 @@ msgstr "不支持批量创建" msgid "Storage is invalid" msgstr "存储无效" -#: terminal/models/session.py:44 +#: terminal/models/session.py:44 terminal/models/sharing.py:81 msgid "Login from" msgstr "登录来源" @@ -3486,6 +3886,50 @@ msgstr "回放" msgid "Date end" msgstr "结束日期" +#: terminal/models/sharing.py:20 +msgid "Creator" +msgstr "创建者" + +#: terminal/models/sharing.py:22 terminal/models/sharing.py:60 +msgid "Verify code" +msgstr "验证码" + +#: terminal/models/sharing.py:27 +msgid "Expired time (min)" +msgstr "过期时间 (分)" + +#: terminal/models/sharing.py:48 +msgid "Link not active" +msgstr "链接失效" + +#: terminal/models/sharing.py:50 +msgid "Link expired" +msgstr "是否过期" + +#: terminal/models/sharing.py:63 +msgid "Session sharing" +msgstr "会话分享" + +#: terminal/models/sharing.py:67 terminal/serializers/sharing.py:49 +msgid "Joiner" +msgstr "加入者" + +#: terminal/models/sharing.py:70 +msgid "Date joined" +msgstr "加入日期" + +#: terminal/models/sharing.py:73 +msgid "Date left" +msgstr "结束日期" + +#: terminal/models/sharing.py:91 xpack/plugins/change_auth_plan/models.py:274 +msgid "Finished" +msgstr "结束" + +#: terminal/models/sharing.py:111 +msgid "Invalid verification code" +msgstr "验证码不正确" + #: terminal/models/status.py:18 msgid "Session Online" msgstr "在线会话" @@ -3584,18 +4028,18 @@ msgstr "" "
\n" " " -#: terminal/notifications.py:94 +#: terminal/notifications.py:99 #, python-format msgid "" "Insecure Command Alert: [%(name)s->%(login_from)s@%(remote_addr)s] $" "%(command)s" msgstr "危险命令告警: [%(name)s->%(login_from)s@%(remote_addr)s] $%(command)s" -#: terminal/notifications.py:112 +#: terminal/notifications.py:117 msgid "Batch danger command alert" msgstr "批量危险命令告警" -#: terminal/notifications.py:123 +#: terminal/notifications.py:128 #, python-format msgid "" "\n" @@ -3626,7 +4070,7 @@ msgstr "" " ----------------- 命令 ----------------
\n" " " -#: terminal/notifications.py:148 +#: terminal/notifications.py:150 #, python-format msgid "Insecure Web Command Execution Alert: [%(name)s]" msgstr "批量危险命令告警: [%(name)s]" @@ -3676,7 +4120,7 @@ msgstr "Secret key" msgid "Endpoint" msgstr "端点" -#: terminal/serializers/storage.py:66 xpack/plugins/cloud/models.py:214 +#: terminal/serializers/storage.py:66 xpack/plugins/cloud/models.py:209 msgid "Region" msgstr "地域" @@ -3724,10 +4168,6 @@ msgstr "忽略证书认证" msgid "Not found" msgstr "没有发现" -#: tickets/api/ticket.py:61 tickets/api/ticket.py:70 -msgid "Ticket already closed" -msgstr "工单已经关闭" - #: tickets/const.py:8 msgid "General" msgstr "一般" @@ -3740,133 +4180,129 @@ msgstr "申请资产" msgid "Apply for application" msgstr "申请应用" -#: tickets/const.py:17 tickets/const.py:24 +#: tickets/const.py:17 tickets/const.py:30 tickets/const.py:35 msgid "Open" msgstr "打开" -#: tickets/const.py:18 +#: tickets/const.py:18 tickets/const.py:25 +msgid "Approved" +msgstr "已同意" + +#: tickets/const.py:19 tickets/const.py:26 +msgid "Rejected" +msgstr "已拒绝" + +#: tickets/const.py:20 tickets/const.py:31 +msgid "Closed" +msgstr "关闭的" + +#: tickets/const.py:24 +msgid "Notified" +msgstr "已通知" + +#: tickets/const.py:37 msgid "Approve" msgstr "同意" -#: tickets/const.py:25 -msgid "Closed" -msgstr "关闭" +#: tickets/const.py:42 +msgid "One level" +msgstr "1 级" -#: tickets/handler/apply_application.py:55 +#: tickets/const.py:43 +msgid "Two level" +msgstr "2 级" + +#: tickets/const.py:47 +msgid "Super admin" +msgstr "超级管理员" + +#: tickets/const.py:48 +msgid "Org admin" +msgstr "组织管理员" + +#: tickets/const.py:49 +msgid "Super admin and org admin" +msgstr "组织管理员或超级管理员" + +#: tickets/const.py:50 +msgid "Custom user" +msgstr "自定义用户" + +#: tickets/errors.py:9 +msgid "Ticket already closed" +msgstr "工单已经关闭" + +#: tickets/handler/apply_application.py:51 msgid "Applied category" msgstr "申请的类别" -#: tickets/handler/apply_application.py:56 +#: tickets/handler/apply_application.py:52 msgid "Applied type" msgstr "申请的类型" -#: tickets/handler/apply_application.py:57 +#: tickets/handler/apply_application.py:53 msgid "Applied application group" msgstr "申请的应用组" -#: tickets/handler/apply_application.py:58 tickets/handler/apply_asset.py:59 +#: tickets/handler/apply_application.py:54 tickets/handler/apply_asset.py:47 msgid "Applied system user group" msgstr "申请的系统用户组" -#: tickets/handler/apply_application.py:59 tickets/handler/apply_asset.py:61 +#: tickets/handler/apply_application.py:55 tickets/handler/apply_asset.py:49 msgid "Applied date start" msgstr "申请的开始日期" -#: tickets/handler/apply_application.py:60 tickets/handler/apply_asset.py:62 +#: tickets/handler/apply_application.py:56 tickets/handler/apply_asset.py:50 msgid "Applied date expired" msgstr "申请的失效日期" -#: tickets/handler/apply_application.py:75 -msgid "Approved applications" -msgstr "批准的应用" - -#: tickets/handler/apply_application.py:76 tickets/handler/apply_asset.py:79 -msgid "Approved system users" -msgstr "批准的系统用户" - -#: tickets/handler/apply_application.py:77 tickets/handler/apply_asset.py:81 -msgid "Approved date start" -msgstr "批准的开始日期" - -#: tickets/handler/apply_application.py:78 tickets/handler/apply_asset.py:82 -msgid "Approved date expired" -msgstr "批准的失效日期" - -#: tickets/handler/apply_application.py:100 tickets/handler/apply_asset.py:103 +#: tickets/handler/apply_application.py:78 tickets/handler/apply_asset.py:71 msgid "" "Created by the ticket, ticket title: {}, ticket applicant: {}, ticket " "processor: {}, ticket ID: {}" msgstr "" "通过工单创建, 工单标题: {}, 工单申请人: {}, 工单处理人: {}, 工单 ID: {}" -#: tickets/handler/apply_asset.py:57 -msgid "Applied IP group" -msgstr "申请的IP组" - -#: tickets/handler/apply_asset.py:58 +#: tickets/handler/apply_asset.py:46 msgid "Applied hostname group" msgstr "申请的主机名组" -#: tickets/handler/apply_asset.py:60 +#: tickets/handler/apply_asset.py:48 msgid "Applied actions" msgstr "申请的动作" -#: tickets/handler/apply_asset.py:78 -msgid "Approved assets" -msgstr "批准的资产" - -#: tickets/handler/apply_asset.py:80 -msgid "Approved actions" -msgstr "批准的动作" - -#: tickets/handler/base.py:62 +#: tickets/handler/base.py:86 msgid "{} {} the ticket" msgstr "{} {}工单" -#: tickets/handler/base.py:91 +#: tickets/handler/base.py:113 msgid "Ticket title" msgstr "工单标题" -#: tickets/handler/base.py:92 +#: tickets/handler/base.py:114 msgid "Ticket type" msgstr "工单类型" -#: tickets/handler/base.py:93 +#: tickets/handler/base.py:115 msgid "Ticket status" msgstr "工单状态" -#: tickets/handler/base.py:94 -msgid "Ticket action" -msgstr "工单动作" - -#: tickets/handler/base.py:95 +#: tickets/handler/base.py:116 msgid "Ticket applicant" msgstr "工单申请人" -#: tickets/handler/base.py:96 -msgid "Ticket assignees" -msgstr "工单受理人" - -#: tickets/handler/base.py:99 -msgid "Ticket processor" -msgstr "工单处理人" - -#: tickets/handler/base.py:100 +#: tickets/handler/base.py:118 msgid "Ticket basic info" msgstr "工单基本信息" -#: tickets/handler/base.py:114 tickets/handler/base.py:121 +#: tickets/handler/base.py:129 msgid "No content" msgstr "无内容" -#: tickets/handler/base.py:116 +#: tickets/handler/base.py:131 msgid "Ticket applied info" msgstr "工单申请信息" -#: tickets/handler/base.py:123 -msgid "Ticket approved info" -msgstr "工单批准信息" - #: tickets/handler/command_confirm.py:24 msgid "Applied run user" msgstr "申请运行的用户" @@ -3927,105 +4363,106 @@ msgstr "用户显示名称" msgid "Body" msgstr "内容" -#: tickets/models/ticket.py:28 -msgid "ugettext_lazy" -msgstr "" +#: tickets/models/flow.py:19 tickets/models/flow.py:55 +#: tickets/models/ticket.py:25 +msgid "Approve level" +msgstr "审批级别" -#: tickets/models/ticket.py:35 -msgid "Title" -msgstr "标题" +#: tickets/models/flow.py:24 tickets/serializers/ticket/ticket.py:140 +msgid "Approve strategy" +msgstr "审批策略" -#: tickets/models/ticket.py:52 -msgid "Applicant" -msgstr "申请人" - -#: tickets/models/ticket.py:55 -msgid "Applicant display" -msgstr "申请人名称" - -#: tickets/models/ticket.py:60 -msgid "Processor" -msgstr "处理人" - -#: tickets/models/ticket.py:63 -msgid "Processor display" -msgstr "处理人名称" - -#: tickets/models/ticket.py:67 +#: tickets/models/flow.py:29 tickets/serializers/ticket/ticket.py:141 msgid "Assignees" msgstr "受理人" -#: tickets/models/ticket.py:70 +#: tickets/models/flow.py:33 msgid "Assignees display" msgstr "受理人名称" +#: tickets/models/flow.py:37 +msgid "Ticket flow approval rule" +msgstr "工单批准信息" + +#: tickets/models/flow.py:60 +msgid "Ticket flow" +msgstr "工单流程" + +#: tickets/models/ticket.py:38 +msgid "Ticket assignee" +msgstr "工单受理人" + +#: tickets/models/ticket.py:45 +msgid "Title" +msgstr "标题" + +#: tickets/models/ticket.py:53 +msgid "State" +msgstr "状态" + +#: tickets/models/ticket.py:61 +msgid "Approval step" +msgstr "审批步骤" + +#: tickets/models/ticket.py:66 +msgid "Applicant" +msgstr "申请人" + +#: tickets/models/ticket.py:68 +msgid "Applicant display" +msgstr "申请人名称" + +#: tickets/models/ticket.py:69 +msgid "Process" +msgstr "流程" + +#: tickets/models/ticket.py:74 +msgid "TicketFlow" +msgstr "工单流程" + +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:16 +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:16 +msgid "Apply name" +msgstr "应用名称" + #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:35 -msgid "Application group" -msgstr "应用组" +msgid "Apply applications" +msgstr "申请应用" -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:39 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:28 -msgid "System user group" -msgstr "系统用户组" +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:40 +msgid "Apply applications display" +msgstr "应用名称名称" -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:53 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:50 -msgid "Permission name" -msgstr "授权名称" +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:44 +msgid "Apply system users" +msgstr "系统用户" -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:56 -msgid "Approve applications" -msgstr "批准的应用" - -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:61 -msgid "Approve applications display" -msgstr "批准的应用名称" - -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:65 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:62 -msgid "Approve system users" -msgstr "批准的系统用户" - -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:70 -msgid "Approve system user display" +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:49 +msgid "Apply system user display" msgstr "批准的系统用户名称" -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:90 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:94 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:69 +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:61 +#: tickets/serializers/ticket/ticket.py:127 msgid "Permission named `{}` already exists" msgstr "授权名称 `{}` 已存在" -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:107 -msgid "No `Application` are found under Organization `{}`" -msgstr "在组织 `{}` 下没有发现 `应用`" - -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:125 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:124 -msgid "No `SystemUser` are found under Organization `{}`" -msgstr "在组织 `{}` 下没有发现 `系统用户`" - #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:20 -msgid "IP group" -msgstr "IP组" +msgid "Apply assets" +msgstr "申请资产" #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:24 -msgid "Hostname group" -msgstr "主机名组" - -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:36 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:57 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:66 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:74 msgid "Approve assets display" msgstr "批准的资产名称" -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:53 -msgid "Approve assets" -msgstr "批准的资产" +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:29 +msgid "Approve system users" +msgstr "批准的系统用户" -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:108 -msgid "No `Asset` are found under Organization `{}`" -msgstr "在组织 `{}` 下没有发现 `资产`" +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:33 +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:41 +msgid "Apply assets display" +msgstr "批准的资产名称" #: tickets/serializers/ticket/meta/ticket_type/command_confirm.py:12 msgid "Run user" @@ -4056,6 +4493,7 @@ msgid "From cmd filter" msgstr "来自命令过滤规则" #: tickets/serializers/ticket/meta/ticket_type/common.py:11 +#: tickets/serializers/ticket/ticket.py:122 msgid "Created by ticket ({}-{})" msgstr "通过工单创建 ({}-{})" @@ -4075,49 +4513,49 @@ msgstr "登录系统用户" msgid "Login datetime" msgstr "登录日期" -#: tickets/serializers/ticket/ticket.py:21 -msgid "Action display" -msgstr "动作名称" - -#: tickets/serializers/ticket/ticket.py:101 +#: tickets/serializers/ticket/ticket.py:95 msgid "" "The `type` in the submission data (`{}`) is different from the type in the " "request url (`{}`)" msgstr "提交数据中的类型 (`{}`) 与请求URL地址中的类型 (`{}`) 不一致" -#: tickets/serializers/ticket/ticket.py:122 -msgid "None of the assignees belong to Organization `{}` admins" -msgstr "所有受理人都不属于组织 `{}` 下的管理员" +#: tickets/serializers/ticket/ticket.py:115 +msgid "The ticket flow `{}` does not exist" +msgstr "工单流程 `{}` 不存在" -#: tickets/utils.py:36 +#: tickets/serializers/ticket/ticket.py:182 +msgid "The current organization type already exists" +msgstr "当前组织已存在该类型" + +#: tickets/utils.py:37 msgid "New Ticket - {} ({})" msgstr "新工单 - {} ({})" -#: tickets/utils.py:38 +#: tickets/utils.py:39 msgid "Your has a new ticket, applicant - {}" msgstr "你有一个新的工单, 申请人 - {}" -#: tickets/utils.py:40 tickets/utils.py:59 +#: tickets/utils.py:41 tickets/utils.py:60 msgid "click here to review" msgstr "点击查看" -#: tickets/utils.py:55 +#: tickets/utils.py:56 msgid "Ticket has processed - {} ({})" msgstr "工单已处理 - {} ({})" -#: tickets/utils.py:57 +#: tickets/utils.py:58 msgid "Your ticket has been processed, processor - {}" msgstr "你的工单已被处理, 处理人 - {}" -#: users/api/user.py:214 +#: users/api/user.py:208 msgid "Could not reset self otp, use profile reset instead" msgstr "不能在该页面重置多因子认证, 请去个人信息页面重置" -#: users/const.py:10 users/models/user.py:174 +#: users/const.py:10 users/models/user.py:179 msgid "System administrator" msgstr "系统管理员" -#: users/const.py:11 users/models/user.py:175 +#: users/const.py:11 users/models/user.py:180 msgid "System auditor" msgstr "系统审计员" @@ -4133,6 +4571,14 @@ msgstr "设置密码" msgid "MFA not enabled" msgstr "MFA没有开启" +#: users/exceptions.py:15 +msgid "Phone not set" +msgstr "手机号没有设置" + +#: users/exceptions.py:20 +msgid "MFA method not support" +msgstr "MFA 方法不支持" + #: users/forms/profile.py:49 msgid "" "When enabled, you will enter the MFA binding process the next time you log " @@ -4200,55 +4646,510 @@ msgid "Public key should not be the same as your old one." msgstr "不能和原来的密钥相同" #: users/forms/profile.py:149 users/serializers/profile.py:74 -#: users/serializers/profile.py:149 users/serializers/profile.py:162 +#: users/serializers/profile.py:150 users/serializers/profile.py:163 msgid "Not a valid ssh public key" msgstr "SSH密钥不合法" -#: users/forms/profile.py:160 users/models/user.py:591 +#: users/forms/profile.py:160 users/models/user.py:634 #: users/templates/users/user_password_update.html:48 msgid "Public key" msgstr "SSH公钥" -#: users/models/user.py:470 +#: users/models/user.py:38 +msgid "SMS verify code" +msgstr "短信验证码" + +#: users/models/user.py:472 msgid "Force enable" msgstr "强制启用" -#: users/models/user.py:540 +#: users/models/user.py:583 msgid "Local" msgstr "数据库" -#: users/models/user.py:574 +#: users/models/user.py:617 msgid "Avatar" msgstr "头像" -#: users/models/user.py:577 +#: users/models/user.py:620 msgid "Wechat" msgstr "微信" -#: users/models/user.py:588 +#: users/models/user.py:631 msgid "Private key" msgstr "ssh私钥" -#: users/models/user.py:607 +#: users/models/user.py:650 msgid "Source" msgstr "来源" -#: users/models/user.py:611 +#: users/models/user.py:654 msgid "Date password last updated" msgstr "最后更新密码日期" -#: users/models/user.py:614 +#: users/models/user.py:657 msgid "Need update password" msgstr "需要更新密码" -#: users/models/user.py:770 +#: users/models/user.py:817 msgid "Administrator" msgstr "管理员" -#: users/models/user.py:773 +#: users/models/user.py:820 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" +#: users/notifications.py:43 users/notifications.py:76 +#: users/templates/users/reset_password.html:5 +#: users/templates/users/reset_password.html:6 +msgid "Reset password" +msgstr "重置密码" + +#: users/notifications.py:44 +msgid "" +"\n" +"Hello %(name)s:\n" +"Please click the link below to reset your password, if not your request, " +"concern your account security\n" +"\n" +"Click here reset password 👇\n" +"%(rest_password_url)s?token=%(rest_password_token)s\n" +"\n" +"This link is valid for 1 hour. After it expires, \n" +"\n" +"request new one 👇\n" +"%(forget_password_url)s?email=%(email)s\n" +"\n" +"-------------------\n" +"\n" +"Login direct 👇\n" +"%(login_url)s\n" +"\n" +msgstr "" +"\n" +"您好 %(name)s:\n" +"请点击下面链接重置密码, 如果不是您申请的,请关注账号安全\n " +"\n" +"请点击这里设置密码 👇\n" +"%(rest_password_url)s?token=%(rest_password_token)s\n" +"\n" +"这个链接有效期1小时, 超过时间您可以, \n" +"\n" +"重新申请 👇\n" +"%(forget_password_url)s?email=%(email)s\n" +"\n" +"-------------------\n" +"\n" +"直接登录 👇\n" +"%(login_url)s\n" +"\n" + +#: users/notifications.py:77 +msgid "" +"\n" +" Hello %(name)s:\n" +"
\n" +" Please click the link below to reset your password, if not your " +"request, concern your account security\n" +"
\n" +" Click here reset password\n" +"
\n" +" This link is valid for 1 hour. After it expires, request new one\n" +" \n" +"
\n" +" ---\n" +" \n" +"
\n" +" Login direct\n" +" \n" +"
\n" +" " +msgstr "" +"\n" +" 您好 %(name)s:\n" +"
\n" +" 请点击下面链接重置密码, 如果不是您申请的,请关注账号安全\n" +"
\n" +" 请点击这" +"里设置密码 \n" +"
\n" +" 这个链接有效期1小时, 超过时间您可以重新申请\n" +"\n" +"
\n" +" ---\n" +"\n" +"
\n" +" 直接登录\n" +"\n" +"
\n" +" " + +#: users/notifications.py:116 users/notifications.py:150 +#: users/views/profile/reset.py:127 +msgid "Reset password success" +msgstr "重置密码成功" + +#: users/notifications.py:117 +msgid "" +"\n" +" \n" +"Hi %(name)s:\n" +"\n" +"Your JumpServer password has just been successfully updated.\n" +"\n" +"If the password update was not initiated by you, your account may have " +"security issues. \n" +"It is recommended that you log on to the JumpServer immediately and change " +"your password.\n" +"\n" +"If you have any questions, you can contact the administrator.\n" +"\n" +"-------------------\n" +"\n" +"\n" +"IP Address: %(ip_address)s\n" +"
\n" +"
\n" +"Browser: %(browser)s\n" +"
\n" +" \n" +" " +msgstr "" +"\n" +" \n" +"Hi %(name)s:\n" +"\n" +"你的 JumpServer 密码刚刚已经成功更新。\n" +"\n" +"如果这次密码更新不是由你发起的,那么你的账号可能存在安全问题。 " +"\n" +"建议你立刻登录 JumpServer 更改密码。 \n" +"\n" +"如果你有任何疑问,可以联系管理员。\n" +"\n" +"-------------------\n" +"\n" +"\n" +"IP 地址: %(ip_address)s\n" +"
\n" +"
\n" +"浏览器: %(browser)s\n" +"
\n" +" \n" +" " + +#: users/notifications.py:151 +msgid "" +"\n" +" \n" +" Hi %(name)s:\n" +"
\n" +" \n" +" \n" +"
\n" +" Your JumpServer password has just been successfully updated.\n" +"
\n" +" \n" +"
\n" +" If the password update was not initiated by you, your account may " +"have security issues. \n" +" It is recommended that you log on to the JumpServer immediately and " +"change your password.\n" +"
\n" +"\n" +"
\n" +" If you have any questions, you can contact the administrator.\n" +"
\n" +"
\n" +" ---\n" +"
\n" +"
\n" +" IP Address: %(ip_address)s\n" +"
\n" +"
\n" +" Browser: %(browser)s\n" +"
\n" +" \n" +" " +msgstr "" +"\n" +" \n" +" Hi %(name)s:\n" +"
\n" +" \n" +" \n" +"
\n" +" 你的 JumpServer 密码刚刚已经成功更新。
\n" +" \n" +"
\n" +" 如果这次密码更新不是由你发起的,那么你的账号可能存在安全问题。\n" +" 建议你立刻登录 JumpServer 更改密码。\n" +"
\n" +" \n" +"
\n" +" 如果你有任何疑问,可以联系管理员。
\n" +"
\n" +" ---\n" +"
\n" +"
\n" +" IP 地址: %(ip_address)s\n" +"
\n" +"
\n" +" 浏览器: %(browser)s\n" +"
\n" +" \n" +" " + +#: users/notifications.py:194 users/notifications.py:230 +msgid "Security notice" +msgstr "安全通知" + +#: users/notifications.py:195 +msgid "" +"\n" +"Hello %(name)s:\n" +"\n" +"Your password will expire in %(date_password_expired)s,\n" +"\n" +"For your account security, please click on the link below to update your " +"password in time\n" +"\n" +"Click here update password 👇\n" +"%(update_password_url)s\n" +"\n" +"If your password has expired, please click 👇 to apply for a password reset " +"email.\n" +"%(forget_password_url)s?email=%(email)s\n" +"\n" +"-------------------\n" +"\n" +"Login direct 👇\n" +"%(login_url)s\n" +"\n" +" " +msgstr "" +"\n" +"您好 %(name)s:\n" +"\n" +"您的密码会在 %(date_password_expired)s 过期,\n" +"\n" +"为了您的账号安全,请点击下面的链接及时更新密码 \n" +"\n" +"请点击这里更新密码 👇\n" +"%(update_password_url)s" +"\n" +"如果您的密码已经过期,请点击 👇 申请一份重置密码邮件。 " +"\n" +"%(forget_password_url)s?email=%(email)s\n" +"\n" +"-------------------\n" +"\n" +"直接登录 👇\n" +"%(login_url)s\n" +"\n" +" " + +#: users/notifications.py:231 +msgid "" +"\n" +" Hello %(name)s:\n" +"
\n" +" Your password will expire in %(date_password_expired)s,\n" +"
\n" +" For your account security, please click on the link below to update " +"your password in time\n" +"
\n" +" Click here update password\n" +"
\n" +" If your password has expired, please click \n" +" Password " +"expired \n" +" to apply for a password reset email.\n" +" \n" +"
\n" +" ---\n" +" \n" +"
\n" +" Login direct\n" +" \n" +"
\n" +" " +msgstr "" +"\n" +" 您好 %(name)s:\n" +"
\n" +" 您的密码会在 %(date_password_expired)s 过期,\n" +"
\n" +" 为了您的账号安全,请点击下面的链接及时更新密码\n" +"
\n" +" 请点击这里更新密码\n" +"
\n" +" 如果您的密码已经过期,请点击 \n" +" 密码过期 \n" +" 申请一份重置密码邮件。\n" +"\n" +"
\n" +" ---\n" +"\n" +"
\n" +" 直接登录\n" +"\n" +"
\n" +" " + +#: users/notifications.py:268 users/notifications.py:287 +msgid "Expiration notice" +msgstr "过期通知" + +#: users/notifications.py:269 +msgid "" +"\n" +"Hello %(name)s:\n" +"\n" +"Your account will expire in %(date_expired)s,\n" +"\n" +"In order not to affect your normal work, please contact the administrator " +"for confirmation.\n" +"\n" +" " +msgstr "" +"\n" +"您好 %(name)s:\n" +"\n" +"您的账户会在 %(date_expired)s 过期,\n" +"\n" +"为了不影响您正常工作,请联系管理员确认。" +"\n" +" " + +#: users/notifications.py:288 +msgid "" +"\n" +" Hello %(name)s:\n" +"
\n" +" Your account will expire in %(date_expired)s,\n" +"
\n" +" In order not to affect your normal work, please contact the " +"administrator for confirmation.\n" +"
\n" +" " +msgstr "" +"\n" +" 您好 %(name)s:\n" +"
\n" +" 您的账户会在 %(date_expired)s 过期,\n" +"
\n" +" 为了不影响您正常工作,请联系管理员确认。\n" +"
\n" +" " + +#: users/notifications.py:308 users/notifications.py:329 +msgid "SSH Key Reset" +msgstr "重置SSH密钥" + +#: users/notifications.py:309 +msgid "" +"\n" +"Hello %(name)s:\n" +"\n" +"Your ssh public key has been reset by site administrator.\n" +"Please login and reset your ssh public key.\n" +"\n" +"Login direct 👇\n" +"%(login_url)s\n" +"\n" +" " +msgstr "" +"\n" +"你好 %(name)s:\n" +"\n" +"您的密钥已被管理员重置,\n" +"请登录并重新设置您的密钥.\n" +"\n" +"直接登录 👇\n" +"%(login_url)s\n" +"\n" +" " + +#: users/notifications.py:330 +msgid "" +"\n" +" Hello %(name)s:\n" +"
\n" +" Your ssh public key has been reset by site administrator.\n" +" Please login and reset your ssh public key.\n" +"
\n" +" Login direct\n" +" \n" +"
\n" +" " +msgstr "" +"\n" +" 你好 %(name)s:\n" +"
\n" +" 您的密钥已被管理员重置,\n" +" 请登录并重新设置您的密钥.\n" +"
\n" +" 直接登录\n" +"\n" +"
\n" +" " + +#: users/notifications.py:352 users/notifications.py:372 +msgid "MFA Reset" +msgstr "重置 MFA" + +#: users/notifications.py:353 +msgid "" +"\n" +"Hello %(name)s:\n" +"\n" +"Your MFA has been reset by site administrator.\n" +"Please login and reset your MFA.\n" +"\n" +"Login direct 👇 \n" +"%(login_url)s\n" +"\n" +" " +msgstr "" +"\n" +"你好 %(name)s:\n" +"\n" +"您的 MFA 已被管理员重置,\n" +"请登录并重新设置您的 MFA.\n" +"\n" +"直接登录 👇 \n" +"%(login_url)s\n" +"\n" +" " + +#: users/notifications.py:373 +msgid "" +"\n" +" Hello %(name)s:\n" +"
\n" +" Your MFA has been reset by site administrator.\n" +" Please login and reset your MFA.\n" +"
\n" +" Login direct\n" +" \n" +"
\n" +" " +msgstr "" +"\n" +" 你好 %(name)s:\n" +"
\n" +" 您的 MFA 已被管理员重置,\n" +" 请登录并重新设置您的 MFA.\n" +"
\n" +" 直接登录\n" +"\n" +"
\n" +" " + #: users/serializers/profile.py:29 msgid "The old password is incorrect" msgstr "旧密码错误" @@ -4265,12 +5166,12 @@ msgstr "新密码不能是最近 {} 次的密码" msgid "The newly set password is inconsistent" msgstr "两次密码不一致" -#: users/serializers/profile.py:120 users/serializers/user.py:75 +#: users/serializers/profile.py:121 users/serializers/user.py:75 msgid "Is first login" msgstr "首次登录" -#: users/serializers/user.py:22 xpack/plugins/change_auth_plan/models.py:65 -#: xpack/plugins/change_auth_plan/serializers.py:33 +#: users/serializers/user.py:22 xpack/plugins/change_auth_plan/models.py:61 +#: xpack/plugins/change_auth_plan/serializers.py:30 msgid "Password strategy" msgstr "密码策略" @@ -4424,11 +5325,6 @@ msgstr "输入您的邮箱, 将会发一封重置邮件到您的邮箱中" msgid "Submit" msgstr "提交" -#: users/templates/users/reset_password.html:5 -#: users/templates/users/reset_password.html:6 users/utils.py:83 -msgid "Reset password" -msgstr "重置密码" - #: users/templates/users/reset_password.html:23 #: users/templates/users/user_password_update.html:64 msgid "Your password must satisfy" @@ -4542,9 +5438,13 @@ msgid "" "operations according to the prompts" msgstr "账号保护已开启,请根据提示完成以下操作" +#: users/templates/users/user_verify_mfa.html:13 +msgid "Open MFA Authenticator and enter the 6-bit dynamic code" +msgstr "请打开MFA验证器,输入6位动态码" + # msgid "Update user" # msgstr "更新用户" -#: users/utils.py:24 +#: users/utils.py:23 #, python-format msgid "" "\n" @@ -4583,263 +5483,15 @@ msgstr "" "
\n" " " -#: users/utils.py:58 +#: users/utils.py:57 msgid "Create account successfully" msgstr "创建账户成功" -#: users/utils.py:62 +#: users/utils.py:61 #, python-format msgid "Hello %(name)s" msgstr "您好 %(name)s" -#: users/utils.py:85 -#, python-format -msgid "" -"\n" -" Hello %(name)s:\n" -"
\n" -" Please click the link below to reset your password, if not your request, " -"concern your account security\n" -"
\n" -" Click " -"here reset password\n" -"
\n" -" This link is valid for 1 hour. After it expires, request new one\n" -"\n" -"
\n" -" ---\n" -"\n" -"
\n" -" Login direct\n" -"\n" -"
\n" -" " -msgstr "" -"\n" -" 您好 %(name)s:\n" -"
\n" -" 请点击下面链接重置密码, 如果不是您申请的,请关注账号安全\n" -"
\n" -" 请点击这" -"里设置密码 \n" -"
\n" -" 这个链接有效期1小时, 超过时间您可以重新申请\n" -"\n" -"
\n" -" ---\n" -"\n" -"
\n" -" 直接登录\n" -"\n" -"
\n" -" " - -#: users/utils.py:116 users/views/profile/reset.py:125 -msgid "Reset password success" -msgstr "重置密码成功" - -#: users/utils.py:118 -#, python-format -msgid "" -"\n" -" \n" -" Hi %(name)s:\n" -"
\n" -" \n" -" \n" -"
\n" -" Your JumpServer password has just been successfully updated.\n" -"
\n" -" \n" -"
\n" -" If the password update was not initiated by you, your account may have " -"security issues. \n" -" It is recommended that you log on to the JumpServer immediately and " -"change your password.\n" -"
\n" -" \n" -"
\n" -" If you have any questions, you can contact the administrator.\n" -"
\n" -"
\n" -" ---\n" -"
\n" -"
\n" -" IP Address: %(ip_address)s\n" -"
\n" -"
\n" -" Browser: %(browser)s\n" -"
\n" -" \n" -" " -msgstr "" -"\n" -" \n" -" Hi %(name)s:\n" -"
\n" -" \n" -" \n" -"
\n" -" 你的 JumpServer 密码刚刚已经成功更新。
\n" -" \n" -"
\n" -" 如果这次密码更新不是由你发起的,那么你的账号可能存在安全问题。\n" -" 建议你立刻登录 JumpServer 更改密码。\n" -"
\n" -" \n" -"
\n" -" 如果你有任何疑问,可以联系管理员。
\n" -"
\n" -" ---\n" -"
\n" -"
\n" -" IP 地址: %(ip_address)s\n" -"
\n" -"
\n" -" 浏览器: %(browser)s\n" -"
\n" -" \n" -" " - -#: users/utils.py:158 -msgid "Security notice" -msgstr "安全通知" - -#: users/utils.py:160 -#, python-format -msgid "" -"\n" -" Hello %(name)s:\n" -"
\n" -" Your password will expire in %(date_password_expired)s,\n" -"
\n" -" For your account security, please click on the link below to update your " -"password in time\n" -"
\n" -" Click here update password\n" -"
\n" -" If your password has expired, please click \n" -" Password expired \n" -" to apply for a password reset email.\n" -"\n" -"
\n" -" ---\n" -"\n" -"
\n" -"
Login direct\n" -"\n" -"
\n" -" " -msgstr "" -"\n" -" 您好 %(name)s:\n" -"
\n" -" 您的密码会在 %(date_password_expired)s 过期,\n" -"
\n" -" 为了您的账号安全,请点击下面的链接及时更新密码\n" -"
\n" -" 请点击这里更新密码\n" -"
\n" -" 如果您的密码已经过期,请点击 \n" -" 密码过期 \n" -" 申请一份重置密码邮件。\n" -"\n" -"
\n" -" ---\n" -"\n" -"
\n" -" 直接登录\n" -"\n" -"
\n" -" " - -#: users/utils.py:196 -msgid "Expiration notice" -msgstr "过期通知" - -#: users/utils.py:198 -#, python-format -msgid "" -"\n" -" Hello %(name)s:\n" -"
\n" -" Your account will expire in %(date_expired)s,\n" -"
\n" -" In order not to affect your normal work, please contact the " -"administrator for confirmation.\n" -"
\n" -" " -msgstr "" -"\n" -" 您好 %(name)s:\n" -"
\n" -" 您的账户会在 %(date_expired)s 过期,\n" -"
\n" -" 为了不影响您正常工作,请联系管理员确认。\n" -"
\n" -" " - -#: users/utils.py:217 -msgid "SSH Key Reset" -msgstr "重置SSH密钥" - -#: users/utils.py:219 -#, python-format -msgid "" -"\n" -" Hello %(name)s:\n" -"
\n" -" Your ssh public key has been reset by site administrator.\n" -" Please login and reset your ssh public key.\n" -"
\n" -" Login direct\n" -"\n" -"
\n" -" " -msgstr "" -"\n" -" 你好 %(name)s:\n" -"
\n" -" 您的密钥已被管理员重置,\n" -" 请登录并重新设置您的密钥.\n" -"
\n" -" Login direct\n" -"\n" -"
\n" -" " - -#: users/utils.py:239 -msgid "MFA Reset" -msgstr "重置 MFA" - -#: users/utils.py:241 -#, python-format -msgid "" -"\n" -" Hello %(name)s:\n" -"
\n" -" Your MFA has been reset by site administrator.\n" -" Please login and reset your MFA.\n" -"
\n" -" Login direct\n" -"\n" -"
\n" -" " -msgstr "" -"\n" -" 你好 %(name)s:\n" -"
\n" -" 您的 MFA 已被管理员重置,\n" -" 请登录并重新设置您的 MFA.\n" -"
\n" -" 登录\n" -"\n" -"
\n" -" " - #: users/views/profile/otp.py:122 users/views/profile/otp.py:161 #: users/views/profile/otp.py:181 msgid "MFA code invalid, or ntp sync server time" @@ -4861,7 +5513,7 @@ msgstr "多因子认证禁用成功" msgid "MFA disable success, return login page" msgstr "多因子认证禁用成功,返回登录页面" -#: users/views/profile/password.py:30 +#: users/views/profile/password.py:32 users/views/profile/password.py:36 msgid "Password invalid" msgstr "用户名或密码无效" @@ -4888,163 +5540,110 @@ msgid "" "password" msgstr "用户来自 {} 请去相应系统修改密码" -#: users/views/profile/reset.py:83 users/views/profile/reset.py:94 +#: users/views/profile/reset.py:84 users/views/profile/reset.py:95 msgid "Token invalid or expired" msgstr "Token错误或失效" -#: users/views/profile/reset.py:99 +#: users/views/profile/reset.py:100 msgid "User auth from {}, go there change password" msgstr "用户认证源来自 {}, 请去相应系统修改密码" -#: users/views/profile/reset.py:106 +#: users/views/profile/reset.py:107 msgid "* Your password does not meet the requirements" msgstr "* 您的密码不符合要求" -#: users/views/profile/reset.py:112 +#: users/views/profile/reset.py:113 msgid "* The new password cannot be the last {} passwords" msgstr "* 新密码不能是最近 {} 次的密码" -#: users/views/profile/reset.py:126 +#: users/views/profile/reset.py:128 msgid "Reset password success, return to login page" msgstr "重置密码成功,返回到登录页面" #: xpack/plugins/change_auth_plan/meta.py:9 -#: xpack/plugins/change_auth_plan/models.py:100 -#: xpack/plugins/change_auth_plan/models.py:201 +#: xpack/plugins/change_auth_plan/models.py:89 +#: xpack/plugins/change_auth_plan/models.py:184 msgid "Change auth plan" msgstr "改密计划" -#: xpack/plugins/change_auth_plan/models.py:40 +#: xpack/plugins/change_auth_plan/models.py:41 msgid "Custom password" msgstr "自定义密码" -#: xpack/plugins/change_auth_plan/models.py:41 -msgid "All assets use the same random password" -msgstr "所有资产使用相同的随机密码" - #: xpack/plugins/change_auth_plan/models.py:42 +msgid "All assets use the same random password" +msgstr "使用相同的随机密码" + +#: xpack/plugins/change_auth_plan/models.py:43 msgid "All assets use different random password" -msgstr "所有资产使用不同的随机密码" +msgstr "使用不同的随机密码" -#: xpack/plugins/change_auth_plan/models.py:46 -msgid "Append SSH KEY" -msgstr "追加新密钥" - -#: xpack/plugins/change_auth_plan/models.py:47 -msgid "Empty and append SSH KEY" -msgstr "清空所有密钥再追加新密钥" - -#: xpack/plugins/change_auth_plan/models.py:48 -msgid "Empty current user and append SSH KEY" -msgstr "清空当前账号密钥再追加新密钥" - -#: xpack/plugins/change_auth_plan/models.py:69 +#: xpack/plugins/change_auth_plan/models.py:65 msgid "Password rules" msgstr "密码规则" -#: xpack/plugins/change_auth_plan/models.py:189 -msgid "Manual trigger" -msgstr "手动触发" - -#: xpack/plugins/change_auth_plan/models.py:190 -msgid "Timing trigger" -msgstr "定时触发" - -#: xpack/plugins/change_auth_plan/models.py:204 +#: xpack/plugins/change_auth_plan/models.py:187 msgid "Change auth plan snapshot" msgstr "改密计划快照" -#: xpack/plugins/change_auth_plan/models.py:218 -#: xpack/plugins/change_auth_plan/serializers.py:166 -msgid "Trigger mode" -msgstr "触发模式" - -#: xpack/plugins/change_auth_plan/models.py:223 -#: xpack/plugins/change_auth_plan/models.py:329 +#: xpack/plugins/change_auth_plan/models.py:202 +#: xpack/plugins/change_auth_plan/models.py:296 msgid "Change auth plan execution" msgstr "改密计划执行" -#: xpack/plugins/change_auth_plan/models.py:302 +#: xpack/plugins/change_auth_plan/models.py:269 msgid "Ready" msgstr "准备" -#: xpack/plugins/change_auth_plan/models.py:303 +#: xpack/plugins/change_auth_plan/models.py:270 msgid "Preflight check" msgstr "改密前的校验" -#: xpack/plugins/change_auth_plan/models.py:304 +#: xpack/plugins/change_auth_plan/models.py:271 msgid "Change auth" msgstr "执行改密" -#: xpack/plugins/change_auth_plan/models.py:305 +#: xpack/plugins/change_auth_plan/models.py:272 msgid "Verify auth" msgstr "验证密码/密钥" -#: xpack/plugins/change_auth_plan/models.py:306 +#: xpack/plugins/change_auth_plan/models.py:273 msgid "Keep auth" msgstr "保存密码/密钥" -#: xpack/plugins/change_auth_plan/models.py:307 -msgid "Finished" -msgstr "结束" - -#: xpack/plugins/change_auth_plan/models.py:333 +#: xpack/plugins/change_auth_plan/models.py:300 msgid "Step" msgstr "步骤" -#: xpack/plugins/change_auth_plan/models.py:350 +#: xpack/plugins/change_auth_plan/models.py:317 msgid "Change auth plan task" msgstr "改密计划任务" -#: xpack/plugins/change_auth_plan/serializers.py:29 -msgid "Change Password" -msgstr "修改密码" - -#: xpack/plugins/change_auth_plan/serializers.py:30 -msgid "Change SSH Key" -msgstr "修改密钥" - -#: xpack/plugins/change_auth_plan/serializers.py:35 -msgid "SSH Key strategy" -msgstr "SSH Key 策略" - -#: xpack/plugins/change_auth_plan/serializers.py:61 +#: xpack/plugins/change_auth_plan/serializers.py:56 msgid "Run times" msgstr "执行次数" -#: xpack/plugins/change_auth_plan/serializers.py:79 -msgid "Require password strategy perform setting" -msgstr "需要密码策略执行设置" +#: xpack/plugins/change_auth_plan/serializers.py:72 +msgid "* Please enter custom password" +msgstr "* 请输入自定义密码" #: xpack/plugins/change_auth_plan/serializers.py:82 -msgid "Require password perform setting" -msgstr "需要密码执行设置" - -#: xpack/plugins/change_auth_plan/serializers.py:85 -msgid "Require password rule perform setting" -msgstr "需要密码规则执行设置" - -#: xpack/plugins/change_auth_plan/serializers.py:97 msgid "* Please enter the correct password length" msgstr "* 请输入正确的密码长度" -#: xpack/plugins/change_auth_plan/serializers.py:100 +#: xpack/plugins/change_auth_plan/serializers.py:85 msgid "* Password length range 6-30 bits" msgstr "* 密码长度范围 6-30 位" -#: xpack/plugins/change_auth_plan/serializers.py:118 -msgid "Require ssh key strategy or ssh key perform setting" -msgstr "需要ssh密钥策略或ssh密钥执行设置" - -#: xpack/plugins/change_auth_plan/utils.py:485 +#: xpack/plugins/change_auth_plan/utils.py:442 msgid "Invalid/incorrect password" msgstr "无效/错误 密码" -#: xpack/plugins/change_auth_plan/utils.py:487 +#: xpack/plugins/change_auth_plan/utils.py:444 msgid "Failed to connect to the host" msgstr "连接主机失败" -#: xpack/plugins/change_auth_plan/utils.py:489 +#: xpack/plugins/change_auth_plan/utils.py:446 msgid "Data could not be sent to remote" msgstr "无法将数据发送到远程" @@ -5140,10 +5739,6 @@ msgstr "云服务商" msgid "Cloud account" msgstr "云账号" -#: xpack/plugins/cloud/models.py:82 xpack/plugins/cloud/serializers.py:207 -msgid "Account" -msgstr "账户" - #: xpack/plugins/cloud/models.py:85 xpack/plugins/cloud/serializers.py:179 msgid "Regions" msgstr "地域" @@ -5152,43 +5747,35 @@ msgstr "地域" msgid "Hostname strategy" msgstr "主机名策略" -#: xpack/plugins/cloud/models.py:97 xpack/plugins/cloud/serializers.py:208 -msgid "Unix admin user" -msgstr "Unix 特权用户" - -#: xpack/plugins/cloud/models.py:101 xpack/plugins/cloud/serializers.py:209 -msgid "Windows admin user" -msgstr "Windows 特权用户" - -#: xpack/plugins/cloud/models.py:107 xpack/plugins/cloud/serializers.py:187 +#: xpack/plugins/cloud/models.py:102 xpack/plugins/cloud/serializers.py:186 msgid "IP network segment group" msgstr "IP网段组" -#: xpack/plugins/cloud/models.py:110 xpack/plugins/cloud/serializers.py:212 +#: xpack/plugins/cloud/models.py:105 xpack/plugins/cloud/serializers.py:208 msgid "Always update" msgstr "总是更新" -#: xpack/plugins/cloud/models.py:116 +#: xpack/plugins/cloud/models.py:111 msgid "Date last sync" msgstr "最后同步日期" -#: xpack/plugins/cloud/models.py:127 xpack/plugins/cloud/models.py:168 +#: xpack/plugins/cloud/models.py:122 xpack/plugins/cloud/models.py:163 msgid "Sync instance task" msgstr "同步实例任务" -#: xpack/plugins/cloud/models.py:179 xpack/plugins/cloud/models.py:224 +#: xpack/plugins/cloud/models.py:174 xpack/plugins/cloud/models.py:219 msgid "Date sync" msgstr "同步日期" -#: xpack/plugins/cloud/models.py:204 +#: xpack/plugins/cloud/models.py:199 msgid "Sync task" msgstr "同步任务" -#: xpack/plugins/cloud/models.py:208 +#: xpack/plugins/cloud/models.py:203 msgid "Sync instance task history" msgstr "同步实例任务历史" -#: xpack/plugins/cloud/models.py:211 +#: xpack/plugins/cloud/models.py:206 msgid "Instance" msgstr "实例" @@ -5344,10 +5931,6 @@ msgstr "" msgid "Client ID" msgstr "客户端 ID" -#: xpack/plugins/cloud/serializers.py:33 -msgid "Client Secret" -msgstr "客户端密钥" - #: xpack/plugins/cloud/serializers.py:36 msgid "Tenant ID" msgstr "租户 ID" @@ -5356,6 +5939,10 @@ msgstr "租户 ID" msgid "Subscription ID" msgstr "订阅 ID" +#: xpack/plugins/cloud/serializers.py:51 +msgid "This field is required" +msgstr "该字段是必填项。" + #: xpack/plugins/cloud/serializers.py:85 xpack/plugins/cloud/serializers.py:89 msgid "API Endpoint" msgstr "API 端点" @@ -5377,11 +5964,6 @@ msgstr "执行次数" msgid "Instance count" msgstr "实例个数" -#: xpack/plugins/cloud/serializers.py:211 -#: xpack/plugins/gathered_user/serializers.py:20 -msgid "Periodic display" -msgstr "定时执行" - #: xpack/plugins/cloud/utils.py:65 msgid "Account unavailable" msgstr "账户无效" @@ -5469,48 +6051,3 @@ msgstr "旗舰版" #: xpack/plugins/license/models.py:77 msgid "Community edition" msgstr "社区版" - -#~ msgid "* Please enter custom password" -#~ msgstr "* 请输入自定义密码" - -#~ msgid "FeiShu Error, Please contact your system administrator" -#~ msgstr "飞书错误,请联系系统管理员" - -#~ msgid "Category(Display)" -#~ msgstr "类别 (显示名称)" - -#~ msgid "Type(Dispaly)" -#~ msgstr "类型 (显示名称)" - -#~ msgid "Users name" -#~ msgstr "用户名" - -#~ msgid "User groups name" -#~ msgstr "用户组名称" - -#~ msgid "Assets name" -#~ msgstr "资产名称" - -#~ msgid "System users name" -#~ msgstr "系统用户名称" - -#~ msgid "Admin user MFA auth" -#~ msgstr "所有管理员启用 MFA" - -#~ msgid "Admin user enable MFA" -#~ msgstr "强制管理员启用 MFA" - -#~ msgid "Password update" -#~ msgstr "密码更新" - -#~ msgid "All user enable MFA" -#~ msgstr "强制所有用户启用 MFA" - -#~ msgid "Application category" -#~ msgstr "应用类别" - -#~ msgid "Application type" -#~ msgstr "应用类型" - -#~ msgid "Trigger" -#~ msgstr "触发" diff --git a/apps/notifications/api/notifications.py b/apps/notifications/api/notifications.py index 5c726e201..3b58d3edc 100644 --- a/apps/notifications/api/notifications.py +++ b/apps/notifications/api/notifications.py @@ -1,18 +1,18 @@ -from django.http import Http404 -from rest_framework.mixins import ListModelMixin, UpdateModelMixin +from rest_framework.mixins import ListModelMixin, UpdateModelMixin, RetrieveModelMixin from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework import status from common.drf.api import JMSGenericViewSet +from common.permissions import IsObjectOwner, IsSuperUser, OnlySuperUserCanList from notifications.notifications import system_msgs -from notifications.models import SystemMsgSubscription +from notifications.models import SystemMsgSubscription, UserMsgSubscription from notifications.backends import BACKEND from notifications.serializers import ( - SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer + SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer, + UserMsgSubscriptionSerializer, ) -__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet') +__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet', 'UserMsgSubscriptionViewSet') class BackendListView(APIView): @@ -70,3 +70,13 @@ class SystemMsgSubscriptionViewSet(ListModelMixin, serializer = self.get_serializer(data, many=True) return Response(data=serializer.data) + + +class UserMsgSubscriptionViewSet(ListModelMixin, + RetrieveModelMixin, + UpdateModelMixin, + JMSGenericViewSet): + lookup_field = 'user_id' + queryset = UserMsgSubscription.objects.all() + serializer_class = UserMsgSubscriptionSerializer + permission_classes = (IsObjectOwner | IsSuperUser, OnlySuperUserCanList) diff --git a/apps/notifications/backends/__init__.py b/apps/notifications/backends/__init__.py index 11a95cf40..a991b9567 100644 --- a/apps/notifications/backends/__init__.py +++ b/apps/notifications/backends/__init__.py @@ -1,11 +1,9 @@ +import importlib + from django.utils.translation import gettext_lazy as _ from django.db import models -from .dingtalk import DingTalk -from .email import Email -from .site_msg import SiteMessage -from .wecom import WeCom -from .feishu import FeiShu +client_name_mapper = {} class BACKEND(models.TextChoices): @@ -14,17 +12,11 @@ class BACKEND(models.TextChoices): DINGTALK = 'dingtalk', _('DingTalk') SITE_MSG = 'site_msg', _('Site message') FEISHU = 'feishu', _('FeiShu') + SMS = 'sms', _('SMS') @property def client(self): - client = { - self.EMAIL: Email, - self.WECOM: WeCom, - self.DINGTALK: DingTalk, - self.SITE_MSG: SiteMessage, - self.FEISHU: FeiShu, - }[self] - return client + return client_name_mapper[self] def get_account(self, user): return self.client.get_account(user) @@ -37,3 +29,8 @@ class BACKEND(models.TextChoices): def filter_enable_backends(cls, backends): enable_backends = [b for b in backends if cls(b).is_enable] return enable_backends + + +for b in BACKEND: + m = importlib.import_module(f'.{b}', __package__) + client_name_mapper[b] = m.backend diff --git a/apps/notifications/backends/dingtalk.py b/apps/notifications/backends/dingtalk.py index 83add673e..ba72091a4 100644 --- a/apps/notifications/backends/dingtalk.py +++ b/apps/notifications/backends/dingtalk.py @@ -14,6 +14,9 @@ class DingTalk(BackendBase): agentid=settings.DINGTALK_AGENTID ) - def send_msg(self, users, msg): + def send_msg(self, users, message, subject=None): accounts, __, __ = self.get_accounts(users) - return self.dingtalk.send_text(accounts, msg) + return self.dingtalk.send_text(accounts, message) + + +backend = DingTalk diff --git a/apps/notifications/backends/email.py b/apps/notifications/backends/email.py index 4e1c27322..390da151a 100644 --- a/apps/notifications/backends/email.py +++ b/apps/notifications/backends/email.py @@ -8,7 +8,10 @@ class Email(BackendBase): account_field = 'email' is_enable_field_in_settings = 'EMAIL_HOST_USER' - def send_msg(self, users, subject, message): + def send_msg(self, users, message, subject): from_email = settings.EMAIL_FROM or settings.EMAIL_HOST_USER accounts, __, __ = self.get_accounts(users) send_mail(subject, message, from_email, accounts, html_message=message) + + +backend = Email diff --git a/apps/notifications/backends/feishu.py b/apps/notifications/backends/feishu.py index 90547299c..434898c8a 100644 --- a/apps/notifications/backends/feishu.py +++ b/apps/notifications/backends/feishu.py @@ -14,6 +14,9 @@ class FeiShu(BackendBase): app_secret=settings.FEISHU_APP_SECRET ) - def send_msg(self, users, msg): + def send_msg(self, users, message, subject=None): accounts, __, __ = self.get_accounts(users) - return self.client.send_text(accounts, msg) + return self.client.send_text(accounts, message) + + +backend = FeiShu diff --git a/apps/notifications/backends/site_msg.py b/apps/notifications/backends/site_msg.py index 0f7468f48..faf539d17 100644 --- a/apps/notifications/backends/site_msg.py +++ b/apps/notifications/backends/site_msg.py @@ -5,10 +5,13 @@ from .base import BackendBase class SiteMessage(BackendBase): account_field = 'id' - def send_msg(self, users, subject, message): + def send_msg(self, users, message, subject): accounts, __, __ = self.get_accounts(users) Client.send_msg(subject, message, user_ids=accounts) @classmethod def is_enable(cls): return True + + +backend = SiteMessage diff --git a/apps/notifications/backends/sms.py b/apps/notifications/backends/sms.py new file mode 100644 index 000000000..77a720a04 --- /dev/null +++ b/apps/notifications/backends/sms.py @@ -0,0 +1,25 @@ +from django.conf import settings + +from common.message.backends.sms.alibaba import AlibabaSMS as Client +from .base import BackendBase + + +class SMS(BackendBase): + account_field = 'phone' + is_enable_field_in_settings = 'SMS_ENABLED' + + def __init__(self): + """ + 暂时只对接阿里,之后再扩展 + """ + self.client = Client( + access_key_id=settings.ALIBABA_ACCESS_KEY_ID, + access_key_secret=settings.ALIBABA_ACCESS_KEY_SECRET + ) + + def send_msg(self, users, sign_name: str, template_code: str, template_param: dict): + accounts, __, __ = self.get_accounts(users) + return self.client.send_sms(accounts, sign_name, template_code, template_param) + + +backend = SMS diff --git a/apps/notifications/backends/wecom.py b/apps/notifications/backends/wecom.py index 80b6f1a22..988c904c2 100644 --- a/apps/notifications/backends/wecom.py +++ b/apps/notifications/backends/wecom.py @@ -15,6 +15,9 @@ class WeCom(BackendBase): agentid=settings.WECOM_AGENTID ) - def send_msg(self, users, msg): + def send_msg(self, users, message, subject=None): accounts, __, __ = self.get_accounts(users) - return self.wecom.send_text(accounts, msg) + return self.wecom.send_text(accounts, message) + + +backend = WeCom diff --git a/apps/notifications/migrations/0001_initial.py b/apps/notifications/migrations/0001_initial.py index ebe79f304..8b52fb84f 100644 --- a/apps/notifications/migrations/0001_initial.py +++ b/apps/notifications/migrations/0001_initial.py @@ -44,7 +44,7 @@ class Migration(migrations.Migration): ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), ('message_type', models.CharField(max_length=128)), ('receive_backends', models.JSONField(default=list)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscriptions', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscription', to=settings.AUTH_USER_MODEL)), ], options={ 'abstract': False, diff --git a/apps/notifications/migrations/0002_auto_20210909_1946.py b/apps/notifications/migrations/0002_auto_20210909_1946.py new file mode 100644 index 000000000..75f71fad7 --- /dev/null +++ b/apps/notifications/migrations/0002_auto_20210909_1946.py @@ -0,0 +1,55 @@ +# Generated by Django 3.1.12 on 2021-09-09 11:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def init_user_msg_subscription(apps, schema_editor): + UserMsgSubscription = apps.get_model('notifications', 'UserMsgSubscription') + User = apps.get_model('users', 'User') + + to_create = [] + users = User.objects.all() + for user in users: + receive_backends = [] + + receive_backends.append('site_msg') + + if user.email: + receive_backends.append('email') + + if user.wecom_id: + receive_backends.append('wecom') + + if user.dingtalk_id: + receive_backends.append('dingtalk') + + if user.feishu_id: + receive_backends.append('feishu') + + to_create.append(UserMsgSubscription(user=user, receive_backends=receive_backends)) + UserMsgSubscription.objects.bulk_create(to_create) + print(f'\n Init user message subscription: {len(to_create)}') + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('notifications', '0001_initial'), + ('users', '0036_user_feishu_id'), + ] + + operations = [ + migrations.RemoveField( + model_name='usermsgsubscription', + name='message_type', + ), + migrations.AlterField( + model_name='usermsgsubscription', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscription', to=settings.AUTH_USER_MODEL), + ), + migrations.RunPython(init_user_msg_subscription) + ] diff --git a/apps/notifications/models/notification.py b/apps/notifications/models/notification.py index 94bd1ad7d..d50168576 100644 --- a/apps/notifications/models/notification.py +++ b/apps/notifications/models/notification.py @@ -6,12 +6,11 @@ __all__ = ('SystemMsgSubscription', 'UserMsgSubscription') class UserMsgSubscription(JMSModel): - message_type = models.CharField(max_length=128) - user = models.ForeignKey('users.User', related_name='user_msg_subscriptions', on_delete=models.CASCADE) + user = models.OneToOneField('users.User', related_name='user_msg_subscription', on_delete=models.CASCADE) receive_backends = models.JSONField(default=list) def __str__(self): - return f'{self.message_type}' + return f'{self.user} subscription: {self.receive_backends}' class SystemMsgSubscription(JMSModel): diff --git a/apps/notifications/notifications.py b/apps/notifications/notifications.py index cac467734..1141c104f 100644 --- a/apps/notifications/notifications.py +++ b/apps/notifications/notifications.py @@ -1,14 +1,16 @@ from typing import Iterable import traceback from itertools import chain +from collections import defaultdict -from django.db.utils import ProgrammingError from celery import shared_task +from common.utils import lazyproperty +from users.models import User from notifications.backends import BACKEND -from .models import SystemMsgSubscription +from .models import SystemMsgSubscription, UserMsgSubscription -__all__ = ('SystemMessage', 'UserMessage') +__all__ = ('SystemMessage', 'UserMessage', 'system_msgs') system_msgs = [] @@ -66,40 +68,55 @@ class Message(metaclass=MessageType): raise NotImplementedError def send_msg(self, users: Iterable, backends: Iterable = BACKEND): + backends = set(backends) + backends.add(BACKEND.SITE_MSG) # 站内信必须发 + for backend in backends: try: backend = BACKEND(backend) + if not backend.is_enable: + continue get_msg_method = getattr(self, f'get_{backend}_msg', self.get_common_msg) - msg = get_msg_method() + + try: + msg = get_msg_method() + except NotImplementedError: + continue + client = backend.client() - if isinstance(msg, dict): - client.send_msg(users, **msg) - else: - client.send_msg(users, msg) + client.send_msg(users, **msg) except: traceback.print_exc() - def get_common_msg(self) -> str: + def get_common_msg(self) -> dict: raise NotImplementedError - def get_dingtalk_msg(self) -> str: + @lazyproperty + def common_msg(self) -> dict: return self.get_common_msg() - def get_wecom_msg(self) -> str: - return self.get_common_msg() + # -------------------------------------------------------------- + # 支持不同发送消息的方式定义自己的消息内容,比如有些支持 html 标签 + def get_dingtalk_msg(self) -> dict: + return self.common_msg + + def get_wecom_msg(self) -> dict: + return self.common_msg + + def get_feishu_msg(self) -> dict: + return self.common_msg def get_email_msg(self) -> dict: - msg = self.get_common_msg() - subject = f'{msg[:80]} ...' if len(msg) >= 80 else msg - return { - 'subject': subject, - 'message': msg - } + return self.common_msg def get_site_msg_msg(self) -> dict: - return self.get_email_msg() + return self.common_msg + + def get_sms_msg(self) -> dict: + raise NotImplementedError + # -------------------------------------------------------------- class SystemMessage(Message): @@ -125,4 +142,16 @@ class SystemMessage(Message): class UserMessage(Message): - pass + user: User + + def __init__(self, user): + self.user = user + + def publish(self): + """ + 发送消息到每个用户配置的接收方式上 + """ + + sub = UserMsgSubscription.objects.get(user=self.user) + + self.send_msg([self.user], sub.receive_backends) diff --git a/apps/notifications/serializers/notifications.py b/apps/notifications/serializers/notifications.py index 7415d46f7..191d90a77 100644 --- a/apps/notifications/serializers/notifications.py +++ b/apps/notifications/serializers/notifications.py @@ -1,7 +1,7 @@ from rest_framework import serializers from common.drf.serializers import BulkModelSerializer -from notifications.models import SystemMsgSubscription +from notifications.models import SystemMsgSubscription, UserMsgSubscription class SystemMsgSubscriptionSerializer(BulkModelSerializer): @@ -27,3 +27,11 @@ class SystemMsgSubscriptionByCategorySerializer(serializers.Serializer): category = serializers.CharField() category_label = serializers.CharField() children = SystemMsgSubscriptionSerializer(many=True) + + +class UserMsgSubscriptionSerializer(BulkModelSerializer): + receive_backends = serializers.ListField(child=serializers.CharField(), read_only=False) + + class Meta: + model = UserMsgSubscription + fields = ('user_id', 'receive_backends',) diff --git a/apps/notifications/signals_handler.py b/apps/notifications/signals_handler.py index 451377557..019d2a3da 100644 --- a/apps/notifications/signals_handler.py +++ b/apps/notifications/signals_handler.py @@ -6,14 +6,14 @@ from django.utils.functional import LazyObject from django.db.models.signals import post_save from django.db.models.signals import post_migrate from django.dispatch import receiver -from django.db.utils import DEFAULT_DB_ALIAS -from django.apps import apps as global_apps from django.apps import AppConfig +from notifications.backends import BACKEND +from users.models import User from common.utils.connection import RedisPubSub from common.utils import get_logger from common.decorator import on_transaction_commit -from .models import SiteMessage, SystemMsgSubscription +from .models import SiteMessage, SystemMsgSubscription, UserMsgSubscription from .notifications import SystemMessage @@ -82,3 +82,13 @@ def create_system_messages(app_config: AppConfig, **kwargs): logger.info(f'Create SystemMsgSubscription: package={app_config.module.__package__} type={message_type}') except ModuleNotFoundError: pass + + +@receiver(post_save, sender=User) +def on_user_post_save(sender, instance, created, **kwargs): + if created: + receive_backends = [] + for backend in BACKEND: + if backend.get_account(instance): + receive_backends.append(backend) + UserMsgSubscription.objects.create(user=instance, receive_backends=receive_backends) diff --git a/apps/notifications/site_msg.py b/apps/notifications/site_msg.py index 1a5c9dc23..6e3f45f9d 100644 --- a/apps/notifications/site_msg.py +++ b/apps/notifications/site_msg.py @@ -2,9 +2,12 @@ from django.db.models import F from django.db import transaction from common.utils.timezone import now +from common.utils import get_logger from users.models import User from .models import SiteMessage as SiteMessageModel, SiteMessageUsers +logger = get_logger(__file__) + class SiteMessageUtil: @@ -14,6 +17,11 @@ class SiteMessageUtil: if not any((user_ids, group_ids, is_broadcast)): raise ValueError('No recipient is specified') + logger.info(f'Site message send: ' + f'user_ids={user_ids} ' + f'group_ids={group_ids} ' + f'subject={subject} ' + f'message={message}') with transaction.atomic(): site_msg = SiteMessageModel.objects.create( subject=subject, message=message, diff --git a/apps/notifications/urls/api_urls.py b/apps/notifications/urls/api_urls.py index 60aaee873..14ed78e52 100644 --- a/apps/notifications/urls/api_urls.py +++ b/apps/notifications/urls/api_urls.py @@ -8,6 +8,7 @@ app_name = 'notifications' router = BulkRouter() router.register('system-msg-subscription', api.SystemMsgSubscriptionViewSet, 'system-msg-subscription') +router.register('user-msg-subscription', api.UserMsgSubscriptionViewSet, 'user-msg-subscription') router.register('site-message', api.SiteMessageViewSet, 'site-message') urlpatterns = [ diff --git a/apps/notifications/ws.py b/apps/notifications/ws.py index 45cbb6d00..4b9d9e4bd 100644 --- a/apps/notifications/ws.py +++ b/apps/notifications/ws.py @@ -4,7 +4,6 @@ from redis.exceptions import ConnectionError from channels.generic.websocket import JsonWebsocketConsumer from common.utils import get_logger -from .models import SiteMessage from .site_msg import SiteMessageUtil from .signals_handler import new_site_msg_chan diff --git a/apps/ops/ansible/inventory.py b/apps/ops/ansible/inventory.py index 707855cc3..df20b06d6 100644 --- a/apps/ops/ansible/inventory.py +++ b/apps/ops/ansible/inventory.py @@ -46,7 +46,7 @@ class BaseHost(Host): if host_data.get('username'): self.set_variable('ansible_user', host_data['username']) - # 添加密码和秘钥 + # 添加密码和密钥 if host_data.get('password'): self.set_variable('ansible_ssh_pass', host_data['password']) if host_data.get('private_key'): diff --git a/apps/ops/mixin.py b/apps/ops/mixin.py index c6f1ce184..e64a763fc 100644 --- a/apps/ops/mixin.py +++ b/apps/ops/mixin.py @@ -159,7 +159,7 @@ class PeriodTaskFormMixin(forms.Form): ) interval = forms.IntegerField( required=False, initial=24, - help_text=_('Tips: (Units: hour)'), label=_("Cycle perform"), + help_text=_('Unit: hour'), label=_("Cycle perform"), ) def get_initial_for_field(self, field, field_name): diff --git a/apps/ops/notifications.py b/apps/ops/notifications.py index d39805b8e..a8c659108 100644 --- a/apps/ops/notifications.py +++ b/apps/ops/notifications.py @@ -18,7 +18,10 @@ class ServerPerformanceMessage(SystemMessage): self._msg = msg def get_common_msg(self): - return self._msg + return { + 'subject': self._msg[:80], + 'message': self._msg + } @classmethod def post_insert_to_db(cls, subscription: SystemMsgSubscription): diff --git a/apps/orgs/mixins/models.py b/apps/orgs/mixins/models.py index 498d90a5a..8176cb439 100644 --- a/apps/orgs/mixins/models.py +++ b/apps/orgs/mixins/models.py @@ -19,6 +19,7 @@ __all__ = [ class OrgManager(models.Manager): + def all_group_by_org(self): from ..models import Organization orgs = list(Organization.objects.all()) diff --git a/apps/orgs/serializers.py b/apps/orgs/serializers.py index 28b59a705..24ef3e8be 100644 --- a/apps/orgs/serializers.py +++ b/apps/orgs/serializers.py @@ -25,7 +25,7 @@ class ResourceStatisticsSerializer(serializers.Serializer): app_perms_amount = serializers.IntegerField(required=False) -class OrgSerializer(ModelSerializer): +class OrgSerializer(BulkModelSerializer): users = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True, required=False) admins = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True, required=False) auditors = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True, required=False) diff --git a/apps/perms/api/application/user_group_permission.py b/apps/perms/api/application/user_group_permission.py index 32d116b2f..34c3eefaa 100644 --- a/apps/perms/api/application/user_group_permission.py +++ b/apps/perms/api/application/user_group_permission.py @@ -19,8 +19,8 @@ class UserGroupGrantedApplicationsApi(CommonApiMixin, ListAPIView): 获取用户组直接授权的应用 """ permission_classes = (IsOrgAdminOrAppUser,) - serializer_class = serializers.ApplicationGrantedSerializer - only_fields = serializers.ApplicationGrantedSerializer.Meta.only_fields + serializer_class = serializers.AppGrantedSerializer + only_fields = serializers.AppGrantedSerializer.Meta.only_fields filterset_fields = ['id', 'name', 'category', 'type', 'comment'] search_fields = ['name', 'comment'] diff --git a/apps/perms/api/application/user_permission/user_permission_applications.py b/apps/perms/api/application/user_permission/user_permission_applications.py index a88217e27..be5fc6745 100644 --- a/apps/perms/api/application/user_permission/user_permission_applications.py +++ b/apps/perms/api/application/user_permission/user_permission_applications.py @@ -24,8 +24,8 @@ __all__ = [ class AllGrantedApplicationsMixin(CommonApiMixin, ListAPIView): - only_fields = serializers.ApplicationGrantedSerializer.Meta.only_fields - serializer_class = serializers.ApplicationGrantedSerializer + only_fields = serializers.AppGrantedSerializer.Meta.only_fields + serializer_class = serializers.AppGrantedSerializer filterset_fields = { 'id': ['exact'], 'name': ['exact'], diff --git a/apps/perms/const.py b/apps/perms/const.py new file mode 100644 index 000000000..5b8e1baca --- /dev/null +++ b/apps/perms/const.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# +from django.db.models import TextChoices +from django.utils.translation import ugettext_lazy as _ diff --git a/apps/perms/migrations/0019_auto_20210906_1044.py b/apps/perms/migrations/0019_auto_20210906_1044.py new file mode 100644 index 000000000..42e480669 --- /dev/null +++ b/apps/perms/migrations/0019_auto_20210906_1044.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.12 on 2021-09-06 02:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('perms', '0018_auto_20210208_1515'), + ] + + operations = [ + migrations.AddField( + model_name='applicationpermission', + name='from_ticket', + field=models.BooleanField(default=False, verbose_name='From ticket'), + ), + migrations.AddField( + model_name='assetpermission', + name='from_ticket', + field=models.BooleanField(default=False, verbose_name='From ticket'), + ), + ] diff --git a/apps/perms/models/base.py b/apps/perms/models/base.py index 05f780e8f..2e62a84bf 100644 --- a/apps/perms/models/base.py +++ b/apps/perms/models/base.py @@ -12,7 +12,6 @@ from common.db.models import UnionQuerySet from common.utils import date_expired_default, lazyproperty from orgs.mixins.models import OrgManager - __all__ = [ 'BasePermission', 'BasePermissionQuerySet' ] @@ -31,11 +30,7 @@ class BasePermissionQuerySet(models.QuerySet): def invalid(self): now = timezone.now() - q = ( - Q(is_active=False) | - Q(date_start__gt=now) | - Q(date_expired__lt=now) - ) + q = (Q(is_active=False) | Q(date_start__gt=now) | Q(date_expired__lt=now)) return self.filter(q) @@ -48,13 +43,15 @@ class BasePermission(OrgModelMixin): id = models.UUIDField(default=uuid.uuid4, primary_key=True) name = models.CharField(max_length=128, verbose_name=_('Name')) users = models.ManyToManyField('users.User', blank=True, verbose_name=_("User"), related_name='%(class)ss') - user_groups = models.ManyToManyField('users.UserGroup', blank=True, verbose_name=_("User group"), related_name='%(class)ss') + user_groups = models.ManyToManyField( + 'users.UserGroup', blank=True, verbose_name=_("User group"), related_name='%(class)ss') is_active = models.BooleanField(default=True, verbose_name=_('Active')) date_start = models.DateTimeField(default=timezone.now, db_index=True, verbose_name=_("Date start")) date_expired = models.DateTimeField(default=date_expired_default, db_index=True, verbose_name=_('Date expired')) created_by = models.CharField(max_length=128, blank=True, verbose_name=_('Created by')) date_created = models.DateTimeField(auto_now_add=True, verbose_name=_('Date created')) comment = models.TextField(verbose_name=_('Comment'), blank=True) + from_ticket = models.BooleanField(default=False, verbose_name=_('From ticket')) objects = BasePermissionManager.from_queryset(BasePermissionQuerySet)() diff --git a/apps/perms/serializers/application/permission.py b/apps/perms/serializers/application/permission.py index ecf6ae3f4..e636eec8c 100644 --- a/apps/perms/serializers/application/permission.py +++ b/apps/perms/serializers/application/permission.py @@ -24,7 +24,7 @@ class ApplicationPermissionSerializer(BulkOrgResourceModelSerializer): fields_small = fields_mini + [ 'category', 'category_display', 'type', 'type_display', 'is_active', 'is_expired', 'is_valid', - 'created_by', 'date_created', 'date_expired', 'date_start', 'comment' + 'created_by', 'date_created', 'date_expired', 'date_start', 'comment', 'from_ticket' ] fields_m2m = [ 'users', 'user_groups', 'applications', 'system_users', @@ -32,7 +32,7 @@ class ApplicationPermissionSerializer(BulkOrgResourceModelSerializer): 'system_users_amount', ] fields = fields_small + fields_m2m - read_only_fields = ['created_by', 'date_created'] + read_only_fields = ['created_by', 'date_created', 'from_ticket'] extra_kwargs = { 'is_expired': {'label': _('Is expired')}, 'is_valid': {'label': _('Is valid')}, diff --git a/apps/perms/serializers/application/user_permission.py b/apps/perms/serializers/application/user_permission.py index 5c722852b..63b681d5a 100644 --- a/apps/perms/serializers/application/user_permission.py +++ b/apps/perms/serializers/application/user_permission.py @@ -6,10 +6,10 @@ from django.utils.translation import ugettext_lazy as _ from assets.models import SystemUser from applications.models import Application -from applications.serializers import ApplicationSerializerMixin +from applications.serializers import AppSerializerMixin __all__ = [ - 'ApplicationGrantedSerializer', 'ApplicationSystemUserSerializer' + 'AppGrantedSerializer', 'ApplicationSystemUserSerializer' ] @@ -26,7 +26,7 @@ class ApplicationSystemUserSerializer(serializers.ModelSerializer): read_only_fields = fields -class ApplicationGrantedSerializer(ApplicationSerializerMixin, serializers.ModelSerializer): +class AppGrantedSerializer(AppSerializerMixin, serializers.ModelSerializer): """ 被授权应用的数据结构 """ diff --git a/apps/perms/serializers/asset/permission.py b/apps/perms/serializers/asset/permission.py index 824a25292..3df2c1173 100644 --- a/apps/perms/serializers/asset/permission.py +++ b/apps/perms/serializers/asset/permission.py @@ -53,7 +53,7 @@ class AssetPermissionSerializer(BulkOrgResourceModelSerializer): fields_small = fields_mini + [ 'is_active', 'is_expired', 'is_valid', 'actions', 'created_by', 'date_created', 'date_expired', - 'date_start', 'comment' + 'date_start', 'comment', 'from_ticket' ] fields_m2m = [ 'users', 'users_display', 'user_groups', 'user_groups_display', 'assets', @@ -62,7 +62,7 @@ class AssetPermissionSerializer(BulkOrgResourceModelSerializer): 'nodes_amount', 'system_users_amount', ] fields = fields_small + fields_m2m - read_only_fields = ['created_by', 'date_created'] + read_only_fields = ['created_by', 'date_created', 'from_ticket'] extra_kwargs = { 'is_expired': {'label': _('Is expired')}, 'is_valid': {'label': _('Is valid')}, diff --git a/apps/perms/signals_handler/__init__.py b/apps/perms/signals_handler/__init__.py index e0b84afea..68a531887 100644 --- a/apps/perms/signals_handler/__init__.py +++ b/apps/perms/signals_handler/__init__.py @@ -1,2 +1,3 @@ -from . import common +from . import asset_permission +from . import app_permission from . import refresh_perms diff --git a/apps/perms/signals_handler/app_permission.py b/apps/perms/signals_handler/app_permission.py new file mode 100644 index 000000000..84ee58807 --- /dev/null +++ b/apps/perms/signals_handler/app_permission.py @@ -0,0 +1,104 @@ +import itertools + +from django.db.models.signals import m2m_changed +from django.dispatch import receiver + +from users.models import User, UserGroup +from assets.models import SystemUser +from applications.models import Application +from common.utils import get_logger +from common.exceptions import M2MReverseNotAllowed +from common.decorator import on_transaction_commit +from common.const.signals import POST_ADD +from perms.models import ApplicationPermission +from applications.models import Account as AppAccount + + +logger = get_logger(__file__) + + +@receiver(m2m_changed, sender=ApplicationPermission.applications.through) +@on_transaction_commit +def on_app_permission_applications_changed(sender, instance, action, reverse, pk_set, **kwargs): + if reverse: + raise M2MReverseNotAllowed + if action != POST_ADD: + return + + logger.debug("Application permission applications change signal received") + system_users = instance.system_users.all() + set_remote_app_asset_system_users_if_need(instance, system_users=system_users) + + apps = Application.objects.filter(pk__in=pk_set) + set_app_accounts(apps, system_users) + + +def set_app_accounts(apps, system_users): + for app, system_user in itertools.product(apps, system_users): + AppAccount.objects.get_or_create( + defaults={'app': app, 'systemuser': system_user}, + app=app, systemuser=system_user + ) + + +def set_remote_app_asset_system_users_if_need(instance: ApplicationPermission, system_users=None, + users=None, groups=None): + if not instance.category_remote_app: + return + + attrs = instance.applications.all().values_list('attrs', flat=True) + asset_ids = [attr['asset'] for attr in attrs if attr.get('asset')] + if not asset_ids: + return + + system_users = system_users or instance.system_users.all() + for system_user in system_users: + system_user.assets.add(*asset_ids) + + if system_user.username_same_with_user: + users = users or instance.users.all() + groups = groups or instance.user_groups.all() + system_user.groups.add(*groups) + system_user.users.add(*users) + + +@receiver(m2m_changed, sender=ApplicationPermission.system_users.through) +@on_transaction_commit +def on_app_permission_system_users_changed(sender, instance, action, reverse, pk_set, **kwargs): + if reverse: + raise M2MReverseNotAllowed + if action != POST_ADD: + return + + logger.debug("Application permission system_users change signal received") + system_users = SystemUser.objects.filter(pk__in=pk_set) + + set_remote_app_asset_system_users_if_need(instance, system_users=system_users) + apps = instance.applications.all() + set_app_accounts(apps, system_users) + + +@receiver(m2m_changed, sender=ApplicationPermission.users.through) +@on_transaction_commit +def on_app_permission_users_changed(sender, instance, action, reverse, pk_set, **kwargs): + if reverse: + raise M2MReverseNotAllowed + if action != POST_ADD: + return + + logger.debug("Application permission users change signal received") + users = User.objects.filter(pk__in=pk_set) + set_remote_app_asset_system_users_if_need(instance, users=users) + + +@receiver(m2m_changed, sender=ApplicationPermission.user_groups.through) +@on_transaction_commit +def on_app_permission_user_groups_changed(sender, instance, action, reverse, pk_set, **kwargs): + if reverse: + raise M2MReverseNotAllowed + if action != POST_ADD: + return + + logger.debug("Application permission user groups change signal received") + groups = UserGroup.objects.filter(pk__in=pk_set) + set_remote_app_asset_system_users_if_need(instance, groups=groups) diff --git a/apps/perms/signals_handler/common.py b/apps/perms/signals_handler/asset_permission.py similarity index 54% rename from apps/perms/signals_handler/common.py rename to apps/perms/signals_handler/asset_permission.py index 7399346db..0b2c1aeee 100644 --- a/apps/perms/signals_handler/common.py +++ b/apps/perms/signals_handler/asset_permission.py @@ -3,19 +3,20 @@ from django.db.models.signals import m2m_changed from django.dispatch import receiver -from users.models import User, UserGroup +from users.models import User from assets.models import SystemUser -from applications.models import Application from common.utils import get_logger +from common.decorator import on_transaction_commit from common.exceptions import M2MReverseNotAllowed from common.const.signals import POST_ADD -from perms.models import AssetPermission, ApplicationPermission +from perms.models import AssetPermission logger = get_logger(__file__) @receiver(m2m_changed, sender=User.groups.through) +@on_transaction_commit def on_user_groups_change(sender, instance, action, reverse, pk_set, **kwargs): """ UserGroup 增加 User 时,增加的 User 需要与 UserGroup 关联的动态系统用户相关联 @@ -39,12 +40,13 @@ def on_user_groups_change(sender, instance, action, reverse, pk_set, **kwargs): @receiver(m2m_changed, sender=AssetPermission.nodes.through) +@on_transaction_commit def on_permission_nodes_changed(instance, action, reverse, pk_set, model, **kwargs): if reverse: raise M2MReverseNotAllowed - if action != POST_ADD: return + logger.debug("Asset permission nodes change signal received") nodes = model.objects.filter(pk__in=pk_set) system_users = instance.system_users.all() @@ -55,12 +57,13 @@ def on_permission_nodes_changed(instance, action, reverse, pk_set, model, **kwar @receiver(m2m_changed, sender=AssetPermission.assets.through) +@on_transaction_commit def on_permission_assets_changed(instance, action, reverse, pk_set, model, **kwargs): if reverse: raise M2MReverseNotAllowed - if action != POST_ADD: return + logger.debug("Asset permission assets change signal received") assets = model.objects.filter(pk__in=pk_set) @@ -71,33 +74,38 @@ def on_permission_assets_changed(instance, action, reverse, pk_set, model, **kwa @receiver(m2m_changed, sender=AssetPermission.system_users.through) +@on_transaction_commit def on_asset_permission_system_users_changed(instance, action, reverse, **kwargs): if reverse: raise M2MReverseNotAllowed - if action != POST_ADD: return + logger.debug("Asset permission system_users change signal received") system_users = kwargs['model'].objects.filter(pk__in=kwargs['pk_set']) assets = instance.assets.all().values_list('id', flat=True) nodes = instance.nodes.all().values_list('id', flat=True) - users = instance.users.all().values_list('id', flat=True) - groups = instance.user_groups.all().values_list('id', flat=True) + for system_user in system_users: system_user.nodes.add(*tuple(nodes)) system_user.assets.add(*tuple(assets)) + + # 动态系统用户,需要关联用户和用户组了 if system_user.username_same_with_user: + users = instance.users.all().values_list('id', flat=True) + groups = instance.user_groups.all().values_list('id', flat=True) system_user.groups.add(*tuple(groups)) system_user.users.add(*tuple(users)) @receiver(m2m_changed, sender=AssetPermission.users.through) +@on_transaction_commit def on_asset_permission_users_changed(instance, action, reverse, pk_set, model, **kwargs): if reverse: raise M2MReverseNotAllowed - if action != POST_ADD: return + logger.debug("Asset permission users change signal received") users = model.objects.filter(pk__in=pk_set) system_users = instance.system_users.all() @@ -109,13 +117,13 @@ def on_asset_permission_users_changed(instance, action, reverse, pk_set, model, @receiver(m2m_changed, sender=AssetPermission.user_groups.through) -def on_asset_permission_user_groups_changed(instance, action, pk_set, model, - reverse, **kwargs): +@on_transaction_commit +def on_asset_permission_user_groups_changed(instance, action, pk_set, model, reverse, **kwargs): if reverse: raise M2MReverseNotAllowed - if action != POST_ADD: return + logger.debug("Asset permission user groups change signal received") groups = model.objects.filter(pk__in=pk_set) system_users = instance.system_users.all() @@ -126,87 +134,6 @@ def on_asset_permission_user_groups_changed(instance, action, pk_set, model, system_user.groups.add(*tuple(groups)) -@receiver(m2m_changed, sender=ApplicationPermission.system_users.through) -def on_application_permission_system_users_changed(sender, instance: ApplicationPermission, action, reverse, pk_set, **kwargs): - if reverse: - raise M2MReverseNotAllowed - if not instance.category_remote_app: - return - if action != POST_ADD: - return - - system_users = SystemUser.objects.filter(pk__in=pk_set) - logger.debug("Application permission system_users change signal received") - attrs = instance.applications.all().values_list('attrs', flat=True) - - asset_ids = [attr['asset'] for attr in attrs if attr.get('asset')] - if not asset_ids: - return - - for system_user in system_users: - system_user.assets.add(*asset_ids) - if system_user.username_same_with_user: - user_ids = instance.users.all().values_list('id', flat=True) - group_ids = instance.user_groups.all().values_list('id', flat=True) - system_user.groups.add(*group_ids) - system_user.users.add(*user_ids) -@receiver(m2m_changed, sender=ApplicationPermission.users.through) -def on_application_permission_users_changed(sender, instance, action, reverse, pk_set, **kwargs): - if reverse: - raise M2MReverseNotAllowed - if not instance.category_remote_app: - return - - if action != POST_ADD: - return - - logger.debug("Application permission users change signal received") - user_ids = User.objects.filter(pk__in=pk_set).values_list('id', flat=True) - system_users = instance.system_users.all() - - for system_user in system_users: - if system_user.username_same_with_user: - system_user.users.add(*user_ids) - - -@receiver(m2m_changed, sender=ApplicationPermission.user_groups.through) -def on_application_permission_user_groups_changed(sender, instance, action, reverse, pk_set, **kwargs): - if reverse: - raise M2MReverseNotAllowed - if not instance.category_remote_app: - return - if action != POST_ADD: - return - - logger.debug("Application permission user groups change signal received") - group_ids = UserGroup.objects.filter(pk__in=pk_set).values_list('id', flat=True) - system_users = instance.system_users.all() - - for system_user in system_users: - if system_user.username_same_with_user: - system_user.groups.add(*group_ids) - - -@receiver(m2m_changed, sender=ApplicationPermission.applications.through) -def on_application_permission_applications_changed(sender, instance, action, reverse, pk_set, **kwargs): - if reverse: - raise M2MReverseNotAllowed - - if not instance.category_remote_app: - return - - if action != POST_ADD: - return - - attrs = Application.objects.filter(id__in=pk_set).values_list('attrs', flat=True) - asset_ids = [attr['asset'] for attr in attrs if attr.get('asset')] - if not asset_ids: - return - - system_users = instance.system_users.all() - - for system_user in system_users: - system_user.assets.add(*asset_ids) diff --git a/apps/settings/api/__init__.py b/apps/settings/api/__init__.py index d7cfa4cec..65438dda1 100644 --- a/apps/settings/api/__init__.py +++ b/apps/settings/api/__init__.py @@ -1,5 +1,10 @@ -from .common import * +from .settings import * from .ldap import * from .wecom import * from .dingtalk import * from .feishu import * +from .public import * +from .email import * +from .alibaba_sms import * +from .tencent_sms import * +from .sms import * diff --git a/apps/settings/api/alibaba_sms.py b/apps/settings/api/alibaba_sms.py new file mode 100644 index 000000000..c7f42f110 --- /dev/null +++ b/apps/settings/api/alibaba_sms.py @@ -0,0 +1,58 @@ +from rest_framework.views import Response +from rest_framework.generics import GenericAPIView +from rest_framework.exceptions import APIException +from rest_framework import status +from django.utils.translation import gettext_lazy as _ + +from common.message.backends.sms import SMS_MESSAGE +from common.message.backends.sms.alibaba import AlibabaSMS +from settings.models import Setting +from common.permissions import IsSuperUser +from common.exceptions import JMSException + +from .. import serializers + + +class AlibabaSMSTestingAPI(GenericAPIView): + permission_classes = (IsSuperUser,) + serializer_class = serializers.AlibabaSMSSettingSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + alibaba_access_key_id = serializer.validated_data['ALIBABA_ACCESS_KEY_ID'] + alibaba_access_key_secret = serializer.validated_data.get('ALIBABA_ACCESS_KEY_SECRET') + alibaba_sms_sign_and_tmpl = serializer.validated_data['ALIBABA_SMS_SIGN_AND_TEMPLATES'] + test_phone = serializer.validated_data.get('SMS_TEST_PHONE') + + if not test_phone: + raise JMSException(code='test_phone_required', detail=_('test_phone is required')) + + if not alibaba_access_key_secret: + secret = Setting.objects.filter(name='ALIBABA_ACCESS_KEY_SECRET').first() + if secret: + alibaba_access_key_secret = secret.cleaned_value + + alibaba_access_key_secret = alibaba_access_key_secret or '' + + try: + client = AlibabaSMS( + access_key_id=alibaba_access_key_id, + access_key_secret=alibaba_access_key_secret + ) + sign, tmpl = SMS_MESSAGE.VERIFICATION_CODE.get_sign_and_tmpl(alibaba_sms_sign_and_tmpl) + + client.send_sms( + phone_numbers=[test_phone], + sign_name=sign, + template_code=tmpl, + template_param={'code': 'test'} + ) + return Response(status=status.HTTP_200_OK, data={'msg': _('Test success')}) + except APIException as e: + try: + error = e.detail['errmsg'] + except: + error = e.detail + return Response(status=status.HTTP_400_BAD_REQUEST, data={'error': error}) diff --git a/apps/settings/api/common.py b/apps/settings/api/common.py deleted file mode 100644 index 5f0e6f89c..000000000 --- a/apps/settings/api/common.py +++ /dev/null @@ -1,192 +0,0 @@ -# -*- coding: utf-8 -*- -# - -from smtplib import SMTPSenderRefused -from rest_framework import generics -from rest_framework.views import Response, APIView -from rest_framework.permissions import AllowAny -from django.conf import settings -from django.core.mail import send_mail, get_connection -from django.utils.translation import ugettext_lazy as _ -from django.templatetags.static import static - -from jumpserver.utils import has_valid_xpack_license -from common.permissions import IsSuperUser -from common.utils import get_logger -from .. import serializers -from ..models import Setting - -logger = get_logger(__file__) - - -class MailTestingAPI(APIView): - permission_classes = (IsSuperUser,) - serializer_class = serializers.MailTestSerializer - success_message = _("Test mail sent to {}, please check") - - def post(self, request): - serializer = self.serializer_class(data=request.data) - serializer.is_valid(raise_exception=True) - - email_host = serializer.validated_data['EMAIL_HOST'] - email_port = serializer.validated_data['EMAIL_PORT'] - email_host_user = serializer.validated_data["EMAIL_HOST_USER"] - email_host_password = serializer.validated_data['EMAIL_HOST_PASSWORD'] - email_from = serializer.validated_data["EMAIL_FROM"] - email_recipient = serializer.validated_data["EMAIL_RECIPIENT"] - email_use_ssl = serializer.validated_data['EMAIL_USE_SSL'] - email_use_tls = serializer.validated_data['EMAIL_USE_TLS'] - - # 设置 settings 的值,会导致动态配置在当前进程失效 - # for k, v in serializer.validated_data.items(): - # if k.startswith('EMAIL'): - # setattr(settings, k, v) - try: - subject = "Test" - message = "Test smtp setting" - email_from = email_from or email_host_user - email_recipient = email_recipient or email_from - connection = get_connection( - host=email_host, port=email_port, - username=email_host_user, password=email_host_password, - use_tls=email_use_tls, use_ssl=email_use_ssl, - ) - send_mail( - subject, message, email_from, [email_recipient], - connection=connection - ) - except SMTPSenderRefused as e: - error = e.smtp_error - if isinstance(error, bytes): - for coding in ('gbk', 'utf8'): - try: - error = error.decode(coding) - except UnicodeDecodeError: - continue - else: - break - return Response({"error": str(error)}, status=400) - except Exception as e: - logger.error(e) - return Response({"error": str(e)}, status=400) - return Response({"msg": self.success_message.format(email_recipient)}) - - -class PublicSettingApi(generics.RetrieveAPIView): - permission_classes = (AllowAny,) - serializer_class = serializers.PublicSettingSerializer - - @staticmethod - def get_logo_urls(): - logo_urls = { - 'logo_logout': static('img/logo.png'), - 'logo_index': static('img/logo_text.png'), - 'login_image': static('img/login_image.jpg'), - 'favicon': static('img/facio.ico') - } - if not settings.XPACK_ENABLED: - return logo_urls - from xpack.plugins.interface.models import Interface - obj = Interface.interface() - if not obj: - return logo_urls - for attr in ['logo_logout', 'logo_index', 'login_image', 'favicon']: - if getattr(obj, attr, '') and getattr(obj, attr).url: - logo_urls.update({attr: getattr(obj, attr).url}) - return logo_urls - - @staticmethod - def get_login_title(): - default_title = _('Welcome to the JumpServer open source Bastion Host') - if not settings.XPACK_ENABLED: - return default_title - from xpack.plugins.interface.models import Interface - return Interface.get_login_title() - - def get_object(self): - instance = { - "data": { - "WINDOWS_SKIP_ALL_MANUAL_PASSWORD": settings.WINDOWS_SKIP_ALL_MANUAL_PASSWORD, - "SECURITY_MAX_IDLE_TIME": settings.SECURITY_MAX_IDLE_TIME, - "XPACK_ENABLED": settings.XPACK_ENABLED, - "LOGIN_CONFIRM_ENABLE": settings.LOGIN_CONFIRM_ENABLE, - "SECURITY_VIEW_AUTH_NEED_MFA": settings.SECURITY_VIEW_AUTH_NEED_MFA, - "SECURITY_MFA_VERIFY_TTL": settings.SECURITY_MFA_VERIFY_TTL, - "OLD_PASSWORD_HISTORY_LIMIT_COUNT": settings.OLD_PASSWORD_HISTORY_LIMIT_COUNT, - "SECURITY_COMMAND_EXECUTION": settings.SECURITY_COMMAND_EXECUTION, - "SECURITY_PASSWORD_EXPIRATION_TIME": settings.SECURITY_PASSWORD_EXPIRATION_TIME, - "SECURITY_LUNA_REMEMBER_AUTH": settings.SECURITY_LUNA_REMEMBER_AUTH, - "XPACK_LICENSE_IS_VALID": has_valid_xpack_license(), - "LOGIN_TITLE": self.get_login_title(), - "LOGO_URLS": self.get_logo_urls(), - "TICKETS_ENABLED": settings.TICKETS_ENABLED, - "PASSWORD_RULE": { - 'SECURITY_PASSWORD_MIN_LENGTH': settings.SECURITY_PASSWORD_MIN_LENGTH, - 'SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH': settings.SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH, - 'SECURITY_PASSWORD_UPPER_CASE': settings.SECURITY_PASSWORD_UPPER_CASE, - 'SECURITY_PASSWORD_LOWER_CASE': settings.SECURITY_PASSWORD_LOWER_CASE, - 'SECURITY_PASSWORD_NUMBER': settings.SECURITY_PASSWORD_NUMBER, - 'SECURITY_PASSWORD_SPECIAL_CHAR': settings.SECURITY_PASSWORD_SPECIAL_CHAR, - }, - "AUTH_WECOM": settings.AUTH_WECOM, - "AUTH_DINGTALK": settings.AUTH_DINGTALK, - "AUTH_FEISHU": settings.AUTH_FEISHU, - 'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED - } - } - return instance - - -class SettingsApi(generics.RetrieveUpdateAPIView): - permission_classes = (IsSuperUser,) - serializer_class_mapper = { - 'all': serializers.SettingsSerializer, - 'basic': serializers.BasicSettingSerializer, - 'terminal': serializers.TerminalSettingSerializer, - 'security': serializers.SecuritySettingSerializer, - 'ldap': serializers.LDAPSettingSerializer, - 'email': serializers.EmailSettingSerializer, - 'email_content': serializers.EmailContentSettingSerializer, - 'wecom': serializers.WeComSettingSerializer, - 'dingtalk': serializers.DingTalkSettingSerializer, - 'feishu': serializers.FeiShuSettingSerializer, - } - - def get_serializer_class(self): - category = self.request.query_params.get('category', serializers.BasicSettingSerializer) - return self.serializer_class_mapper.get(category, serializers.BasicSettingSerializer) - - def get_fields(self): - serializer = self.get_serializer_class()() - fields = serializer.get_fields() - return fields - - def get_object(self): - items = self.get_fields().keys() - obj = {item: getattr(settings, item) for item in items} - return obj - - def parse_serializer_data(self, serializer): - data = [] - fields = self.get_fields() - encrypted_items = [name for name, field in fields.items() if field.write_only] - category = self.request.query_params.get('category', '') - for name, value in serializer.validated_data.items(): - encrypted = name in encrypted_items - if encrypted and value in ['', None]: - continue - data.append({ - 'name': name, 'value': value, - 'encrypted': encrypted, 'category': category - }) - return data - - def perform_update(self, serializer): - settings_items = self.parse_serializer_data(serializer) - serializer_data = getattr(serializer, 'data', {}) - for item in settings_items: - changed, setting = Setting.update_or_create(**item) - if not changed: - continue - serializer_data[setting.name] = setting.cleaned_value - setattr(serializer, '_data', serializer_data) diff --git a/apps/settings/api/email.py b/apps/settings/api/email.py new file mode 100644 index 000000000..91163213a --- /dev/null +++ b/apps/settings/api/email.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# + +from smtplib import SMTPSenderRefused +from rest_framework.views import Response, APIView +from django.core.mail import send_mail, get_connection +from django.utils.translation import ugettext_lazy as _ + +from common.permissions import IsSuperUser +from common.utils import get_logger +from .. import serializers + +logger = get_logger(__file__) + +__all__ = ['MailTestingAPI'] + + +class MailTestingAPI(APIView): + permission_classes = (IsSuperUser,) + serializer_class = serializers.MailTestSerializer + success_message = _("Test mail sent to {}, please check") + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + email_host = serializer.validated_data['EMAIL_HOST'] + email_port = serializer.validated_data['EMAIL_PORT'] + email_host_user = serializer.validated_data["EMAIL_HOST_USER"] + email_host_password = serializer.validated_data['EMAIL_HOST_PASSWORD'] + email_from = serializer.validated_data["EMAIL_FROM"] + email_recipient = serializer.validated_data["EMAIL_RECIPIENT"] + email_use_ssl = serializer.validated_data['EMAIL_USE_SSL'] + email_use_tls = serializer.validated_data['EMAIL_USE_TLS'] + + # 设置 settings 的值,会导致动态配置在当前进程失效 + # for k, v in serializer.validated_data.items(): + # if k.startswith('EMAIL'): + # setattr(settings, k, v) + try: + subject = "Test" + message = "Test smtp setting" + email_from = email_from or email_host_user + email_recipient = email_recipient or email_from + connection = get_connection( + host=email_host, port=email_port, + username=email_host_user, password=email_host_password, + use_tls=email_use_tls, use_ssl=email_use_ssl, + ) + send_mail( + subject, message, email_from, [email_recipient], + connection=connection + ) + except SMTPSenderRefused as e: + error = e.smtp_error + if isinstance(error, bytes): + for coding in ('gbk', 'utf8'): + try: + error = error.decode(coding) + except UnicodeDecodeError: + continue + else: + break + return Response({"error": str(error)}, status=400) + except Exception as e: + logger.error(e) + return Response({"error": str(e)}, status=400) + return Response({"msg": self.success_message.format(email_recipient)}) \ No newline at end of file diff --git a/apps/settings/api/public.py b/apps/settings/api/public.py new file mode 100644 index 000000000..b15f0c6f7 --- /dev/null +++ b/apps/settings/api/public.py @@ -0,0 +1,80 @@ +from rest_framework import generics +from rest_framework.permissions import AllowAny +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from django.templatetags.static import static + +from jumpserver.utils import has_valid_xpack_license +from common.utils import get_logger +from .. import serializers + +logger = get_logger(__name__) + +__all__ = ['PublicSettingApi'] + + +class PublicSettingApi(generics.RetrieveAPIView): + permission_classes = (AllowAny,) + serializer_class = serializers.PublicSettingSerializer + + @staticmethod + def get_logo_urls(): + logo_urls = { + 'logo_logout': static('img/logo.png'), + 'logo_index': static('img/logo_text.png'), + 'login_image': static('img/login_image.jpg'), + 'favicon': static('img/facio.ico') + } + if not settings.XPACK_ENABLED: + return logo_urls + from xpack.plugins.interface.models import Interface + obj = Interface.interface() + if not obj: + return logo_urls + for attr in ['logo_logout', 'logo_index', 'login_image', 'favicon']: + if getattr(obj, attr, '') and getattr(obj, attr).url: + logo_urls.update({attr: getattr(obj, attr).url}) + return logo_urls + + @staticmethod + def get_login_title(): + default_title = _('Welcome to the JumpServer open source Bastion Host') + if not settings.XPACK_ENABLED: + return default_title + from xpack.plugins.interface.models import Interface + return Interface.get_login_title() + + def get_object(self): + instance = { + "data": { + "WINDOWS_SKIP_ALL_MANUAL_PASSWORD": settings.WINDOWS_SKIP_ALL_MANUAL_PASSWORD, + "SECURITY_MAX_IDLE_TIME": settings.SECURITY_MAX_IDLE_TIME, + "XPACK_ENABLED": settings.XPACK_ENABLED, + "LOGIN_CONFIRM_ENABLE": settings.LOGIN_CONFIRM_ENABLE, + "SECURITY_VIEW_AUTH_NEED_MFA": settings.SECURITY_VIEW_AUTH_NEED_MFA, + "SECURITY_MFA_VERIFY_TTL": settings.SECURITY_MFA_VERIFY_TTL, + "OLD_PASSWORD_HISTORY_LIMIT_COUNT": settings.OLD_PASSWORD_HISTORY_LIMIT_COUNT, + "SECURITY_COMMAND_EXECUTION": settings.SECURITY_COMMAND_EXECUTION, + "SECURITY_PASSWORD_EXPIRATION_TIME": settings.SECURITY_PASSWORD_EXPIRATION_TIME, + "SECURITY_LUNA_REMEMBER_AUTH": settings.SECURITY_LUNA_REMEMBER_AUTH, + "XPACK_LICENSE_IS_VALID": has_valid_xpack_license(), + "LOGIN_TITLE": self.get_login_title(), + "LOGO_URLS": self.get_logo_urls(), + "TICKETS_ENABLED": settings.TICKETS_ENABLED, + "PASSWORD_RULE": { + 'SECURITY_PASSWORD_MIN_LENGTH': settings.SECURITY_PASSWORD_MIN_LENGTH, + 'SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH': settings.SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH, + 'SECURITY_PASSWORD_UPPER_CASE': settings.SECURITY_PASSWORD_UPPER_CASE, + 'SECURITY_PASSWORD_LOWER_CASE': settings.SECURITY_PASSWORD_LOWER_CASE, + 'SECURITY_PASSWORD_NUMBER': settings.SECURITY_PASSWORD_NUMBER, + 'SECURITY_PASSWORD_SPECIAL_CHAR': settings.SECURITY_PASSWORD_SPECIAL_CHAR, + }, + "AUTH_WECOM": settings.AUTH_WECOM, + "AUTH_DINGTALK": settings.AUTH_DINGTALK, + "AUTH_FEISHU": settings.AUTH_FEISHU, + 'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED, + 'SECURITY_SESSION_SHARE': settings.SECURITY_SESSION_SHARE, + "XRDP_ENABLED": settings.XRDP_ENABLED, + } + } + return instance diff --git a/apps/settings/api/settings.py b/apps/settings/api/settings.py new file mode 100644 index 000000000..4dda51408 --- /dev/null +++ b/apps/settings/api/settings.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# + +from rest_framework import generics +from django.conf import settings + +from jumpserver.conf import Config +from common.permissions import IsSuperUser +from common.utils import get_logger +from .. import serializers +from ..models import Setting + +logger = get_logger(__file__) + + +class SettingsApi(generics.RetrieveUpdateAPIView): + permission_classes = (IsSuperUser,) + serializer_class_mapper = { + 'all': serializers.SettingsSerializer, + 'basic': serializers.BasicSettingSerializer, + 'terminal': serializers.TerminalSettingSerializer, + 'security': serializers.SecuritySettingSerializer, + 'ldap': serializers.LDAPSettingSerializer, + 'email': serializers.EmailSettingSerializer, + 'email_content': serializers.EmailContentSettingSerializer, + 'wecom': serializers.WeComSettingSerializer, + 'dingtalk': serializers.DingTalkSettingSerializer, + 'feishu': serializers.FeiShuSettingSerializer, + 'auth': serializers.AuthSettingSerializer, + 'oidc': serializers.OIDCSettingSerializer, + 'keycloak': serializers.KeycloakSettingSerializer, + 'radius': serializers.RadiusSettingSerializer, + 'cas': serializers.CASSettingSerializer, + 'sso': serializers.SSOSettingSerializer, + 'clean': serializers.CleaningSerializer, + 'other': serializers.OtherSettingSerializer, + 'alibaba': serializers.AlibabaSMSSettingSerializer, + 'tencent': serializers.TencentSMSSettingSerializer, + } + + def get_serializer_class(self): + category = self.request.query_params.get('category', 'basic') + default = serializers.BasicSettingSerializer + cls = self.serializer_class_mapper.get(category, default) + return cls + + def get_fields(self): + serializer = self.get_serializer_class()() + fields = serializer.get_fields() + return fields + + def get_object(self): + items = self.get_fields().keys() + obj = {} + for item in items: + if hasattr(settings, item): + obj[item] = getattr(settings, item) + else: + obj[item] = Config.defaults[item] + return obj + + def parse_serializer_data(self, serializer): + data = [] + fields = self.get_fields() + encrypted_items = [name for name, field in fields.items() if field.write_only] + category = self.request.query_params.get('category', '') + for name, value in serializer.validated_data.items(): + encrypted = name in encrypted_items + if encrypted and value in ['', None]: + continue + data.append({ + 'name': name, 'value': value, + 'encrypted': encrypted, 'category': category + }) + return data + + def perform_update(self, serializer): + settings_items = self.parse_serializer_data(serializer) + serializer_data = getattr(serializer, 'data', {}) + for item in settings_items: + changed, setting = Setting.update_or_create(**item) + if not changed: + continue + serializer_data[setting.name] = setting.cleaned_value + setattr(serializer, '_data', serializer_data) + if hasattr(serializer, 'post_save'): + serializer.post_save() diff --git a/apps/settings/api/sms.py b/apps/settings/api/sms.py new file mode 100644 index 000000000..194dbc608 --- /dev/null +++ b/apps/settings/api/sms.py @@ -0,0 +1,22 @@ +from rest_framework.generics import ListAPIView +from rest_framework.response import Response + +from common.permissions import IsSuperUser +from common.message.backends.sms import BACKENDS +from settings.serializers.sms import SMSBackendSerializer + + +class SMSBackendAPI(ListAPIView): + permission_classes = (IsSuperUser,) + serializer_class = SMSBackendSerializer + + def list(self, request, *args, **kwargs): + data = [ + { + 'name': b, + 'label': b.label + } + for b in BACKENDS + ] + + return Response(data) diff --git a/apps/settings/api/tencent_sms.py b/apps/settings/api/tencent_sms.py new file mode 100644 index 000000000..27ad07327 --- /dev/null +++ b/apps/settings/api/tencent_sms.py @@ -0,0 +1,63 @@ +from collections import OrderedDict + +from rest_framework.views import Response +from rest_framework.generics import GenericAPIView +from rest_framework.exceptions import APIException +from rest_framework import status +from django.utils.translation import gettext_lazy as _ + +from common.message.backends.sms import SMS_MESSAGE +from common.message.backends.sms.tencent import TencentSMS +from settings.models import Setting +from common.permissions import IsSuperUser +from common.exceptions import JMSException + +from .. import serializers + + +class TencentSMSTestingAPI(GenericAPIView): + permission_classes = (IsSuperUser,) + serializer_class = serializers.TencentSMSSettingSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + tencent_secret_id = serializer.validated_data['TENCENT_SECRET_ID'] + tencent_secret_key = serializer.validated_data.get('TENCENT_SECRET_KEY') + tencent_sms_sign_and_tmpl = serializer.validated_data['TENCENT_SMS_SIGN_AND_TEMPLATES'] + tencent_sdkappid = serializer.validated_data.get('TENCENT_SDKAPPID') + + test_phone = serializer.validated_data.get('SMS_TEST_PHONE') + + if not test_phone: + raise JMSException(code='test_phone_required', detail=_('test_phone is required')) + + if not tencent_secret_key: + secret = Setting.objects.filter(name='TENCENT_SECRET_KEY').first() + if secret: + tencent_secret_key = secret.cleaned_value + + tencent_secret_key = tencent_secret_key or '' + + try: + client = TencentSMS( + secret_id=tencent_secret_id, + secret_key=tencent_secret_key, + sdkappid=tencent_sdkappid + ) + sign, tmpl = SMS_MESSAGE.VERIFICATION_CODE.get_sign_and_tmpl(tencent_sms_sign_and_tmpl) + + client.send_sms( + phone_numbers=[test_phone], + sign_name=sign, + template_code=tmpl, + template_param=OrderedDict(code='test') + ) + return Response(status=status.HTTP_200_OK, data={'msg': _('Test success')}) + except APIException as e: + try: + error = e.detail['errmsg'] + except: + error = e.detail + return Response(status=status.HTTP_400_BAD_REQUEST, data={'error': error}) diff --git a/apps/settings/migrations/0003_auto_20210901_1035.py b/apps/settings/migrations/0003_auto_20210901_1035.py new file mode 100644 index 000000000..c6f37625a --- /dev/null +++ b/apps/settings/migrations/0003_auto_20210901_1035.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.12 on 2021-09-01 02:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('settings', '0002_auto_20210729_1546'), + ] + + operations = [ + migrations.AlterField( + model_name='setting', + name='value', + field=models.TextField(blank=True, null=True, verbose_name='Value'), + ), + ] diff --git a/apps/settings/models.py b/apps/settings/models.py index 0a986efcb..1660e318d 100644 --- a/apps/settings/models.py +++ b/apps/settings/models.py @@ -27,7 +27,7 @@ class SettingManager(models.Manager): class Setting(models.Model): name = models.CharField(max_length=128, unique=True, verbose_name=_("Name")) - value = models.TextField(verbose_name=_("Value")) + value = models.TextField(verbose_name=_("Value"), null=True, blank=True) category = models.CharField(max_length=128, default="default") encrypted = models.BooleanField(default=False) enabled = models.BooleanField(verbose_name=_("Enabled"), default=True) diff --git a/apps/settings/serializers/__init__.py b/apps/settings/serializers/__init__.py index 5868a76df..0a55f645d 100644 --- a/apps/settings/serializers/__init__.py +++ b/apps/settings/serializers/__init__.py @@ -1,7 +1,13 @@ # coding: utf-8 # +from .basic import * +from .auth import * from .email import * -from .ldap import * from .public import * from .settings import * +from .security import * +from .terminal import * +from .cleaning import * +from .other import * + diff --git a/apps/settings/serializers/auth/__init__.py b/apps/settings/serializers/auth/__init__.py new file mode 100644 index 000000000..4a2f77ebe --- /dev/null +++ b/apps/settings/serializers/auth/__init__.py @@ -0,0 +1,10 @@ +from .cas import * +from .ldap import * +from .oidc import * +from .radius import * +from .dingtalk import * +from .feishu import * +from .wecom import * +from .sso import * +from .base import * +from .sms import * diff --git a/apps/settings/serializers/auth/base.py b/apps/settings/serializers/auth/base.py new file mode 100644 index 000000000..d98816108 --- /dev/null +++ b/apps/settings/serializers/auth/base.py @@ -0,0 +1,25 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'AuthSettingSerializer', +] + + +class AuthSettingSerializer(serializers.Serializer): + AUTH_CAS = serializers.BooleanField(required=False, label=_('CAS Auth')) + AUTH_OPENID = serializers.BooleanField(required=False, label=_('OPENID Auth')) + AUTH_RADIUS = serializers.BooleanField(required=False, label=_('RADIUS Auth')) + AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('DingTalk Auth')) + AUTH_FEISHU = serializers.BooleanField(default=False, label=_('FeiShu Auth')) + AUTH_WECOM = serializers.BooleanField(default=False, label=_('WeCom Auth')) + AUTH_SSO = serializers.BooleanField(default=False, label=_("SSO Auth")) + FORGOT_PASSWORD_URL = serializers.CharField( + required=False, max_length=1024, label=_("Forgot password url") + ) + HEALTH_CHECK_TOKEN = serializers.CharField( + required=False, max_length=1024, label=_("Health check token") + ) + LOGIN_REDIRECT_MSG_ENABLED = serializers.BooleanField( + required=False, label=_("Enable login redirect msg") + ) diff --git a/apps/settings/serializers/auth/cas.py b/apps/settings/serializers/auth/cas.py new file mode 100644 index 000000000..49a02505f --- /dev/null +++ b/apps/settings/serializers/auth/cas.py @@ -0,0 +1,18 @@ + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'CASSettingSerializer', +] + + +class CASSettingSerializer(serializers.Serializer): + AUTH_CAS = serializers.BooleanField(required=False, label=_('Enable CAS Auth')) + CAS_SERVER_URL = serializers.CharField(required=False, max_length=1024, label=_('Server url')) + CAS_LOGOUT_COMPLETELY = serializers.BooleanField(required=False, label=_('Logout completely')) + CAS_VERSION = serializers.IntegerField(required=False, label=_('Version')) + CAS_USERNAME_ATTRIBUTE = serializers.CharField(required=False, max_length=1024, label=_('Username attr')) + CAS_APPLY_ATTRIBUTES_TO_USER = serializers.BooleanField(required=False, label=_('Enable attributes map')) + CAS_RENAME_ATTRIBUTES = serializers.DictField(required=False, label=_('Rename attr')) + CAS_CREATE_USER = serializers.BooleanField(required=False, label=_('Create user if not')) diff --git a/apps/settings/serializers/auth/dingtalk.py b/apps/settings/serializers/auth/dingtalk.py new file mode 100644 index 000000000..062f19f26 --- /dev/null +++ b/apps/settings/serializers/auth/dingtalk.py @@ -0,0 +1,11 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = ['DingTalkSettingSerializer'] + + +class DingTalkSettingSerializer(serializers.Serializer): + DINGTALK_AGENTID = serializers.CharField(max_length=256, required=True, label='AgentId') + DINGTALK_APPKEY = serializers.CharField(max_length=256, required=True, label='AppKey') + DINGTALK_APPSECRET = serializers.CharField(max_length=256, required=False, label='AppSecret', write_only=True) + AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('Enable DingTalk Auth')) diff --git a/apps/settings/serializers/auth/feishu.py b/apps/settings/serializers/auth/feishu.py new file mode 100644 index 000000000..68b7ee2b1 --- /dev/null +++ b/apps/settings/serializers/auth/feishu.py @@ -0,0 +1,11 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = ['FeiShuSettingSerializer'] + + +class FeiShuSettingSerializer(serializers.Serializer): + FEISHU_APP_ID = serializers.CharField(max_length=256, required=True, label='App ID') + FEISHU_APP_SECRET = serializers.CharField(max_length=256, required=False, label='App Secret', write_only=True) + AUTH_FEISHU = serializers.BooleanField(default=False, label=_('Enable FeiShu Auth')) + diff --git a/apps/settings/serializers/auth/ldap.py b/apps/settings/serializers/auth/ldap.py new file mode 100644 index 000000000..d4eba4089 --- /dev/null +++ b/apps/settings/serializers/auth/ldap.py @@ -0,0 +1,74 @@ + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'LDAPTestConfigSerializer', 'LDAPUserSerializer', 'LDAPTestLoginSerializer', + 'LDAPSettingSerializer', +] + + +class LDAPTestConfigSerializer(serializers.Serializer): + AUTH_LDAP_SERVER_URI = serializers.CharField(max_length=1024) + AUTH_LDAP_BIND_DN = serializers.CharField(max_length=1024, required=False, allow_blank=True) + AUTH_LDAP_BIND_PASSWORD = serializers.CharField(required=False, allow_blank=True) + AUTH_LDAP_SEARCH_OU = serializers.CharField() + AUTH_LDAP_SEARCH_FILTER = serializers.CharField() + AUTH_LDAP_USER_ATTR_MAP = serializers.CharField() + AUTH_LDAP_START_TLS = serializers.BooleanField(required=False) + AUTH_LDAP = serializers.BooleanField(required=False) + + +class LDAPTestLoginSerializer(serializers.Serializer): + username = serializers.CharField(max_length=1024, required=True) + password = serializers.CharField(max_length=2014, required=True) + + +class LDAPUserSerializer(serializers.Serializer): + id = serializers.CharField() + username = serializers.CharField() + name = serializers.CharField() + email = serializers.CharField() + existing = serializers.BooleanField(read_only=True) + + +class LDAPSettingSerializer(serializers.Serializer): + # encrypt_fields 现在使用 write_only 来判断了 + + AUTH_LDAP_SERVER_URI = serializers.CharField( + required=True, max_length=1024, label=_('LDAP server'), + help_text=_('eg: ldap://localhost:389') + ) + AUTH_LDAP_BIND_DN = serializers.CharField(required=False, max_length=1024, label=_('Bind DN')) + AUTH_LDAP_BIND_PASSWORD = serializers.CharField(max_length=1024, write_only=True, required=False, + label=_('Password')) + AUTH_LDAP_SEARCH_OU = serializers.CharField( + max_length=1024, allow_blank=True, required=False, label=_('User OU'), + help_text=_('Use | split multi OUs') + ) + AUTH_LDAP_SEARCH_FILTER = serializers.CharField( + max_length=1024, required=True, label=_('User search filter'), + help_text=_('Choice may be (cn|uid|sAMAccountName)=%(user)s)') + ) + AUTH_LDAP_USER_ATTR_MAP = serializers.DictField( + required=True, label=_('User attr map'), + help_text=_('User attr map present how to map LDAP user attr to ' + 'jumpserver, username,name,email is jumpserver attr') + ) + AUTH_LDAP_SYNC_IS_PERIODIC = serializers.BooleanField(required=False, label=_('Periodic display')) + AUTH_LDAP_SYNC_INTERVAL = serializers.CharField( + required=False, max_length=1024, allow_null=True, + label=_('Interval'), help_text=_('Unit: hour') + ) + AUTH_LDAP_SYNC_CRONTAB = serializers.CharField( + required=False, max_length=1024, allow_null=True, label=_('Regularly perform') + ) + AUTH_LDAP_CONNECT_TIMEOUT = serializers.IntegerField(required=False, label=_('Connect timeout')) + AUTH_LDAP_SEARCH_PAGED_SIZE = serializers.IntegerField(required=False, label=_('Search paged size')) + + AUTH_LDAP = serializers.BooleanField(required=False, label=_('Enable LDAP auth')) + + @staticmethod + def post_save(): + from users.tasks import import_ldap_user_periodic + import_ldap_user_periodic() diff --git a/apps/settings/serializers/auth/oidc.py b/apps/settings/serializers/auth/oidc.py new file mode 100644 index 000000000..21f0f989e --- /dev/null +++ b/apps/settings/serializers/auth/oidc.py @@ -0,0 +1,78 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'OIDCSettingSerializer', 'KeycloakSettingSerializer', +] + + +class CommonSettingSerializer(serializers.Serializer): + # OpenID 公有配置参数 (version <= 1.5.8 或 version >= 1.5.8) + BASE_SITE_URL = serializers.CharField( + required=False, allow_null=True, max_length=1024, label=_('Base site url') + ) + AUTH_OPENID_CLIENT_ID = serializers.CharField( + required=False, max_length=1024, label=_('Client Id') + ) + AUTH_OPENID_CLIENT_SECRET = serializers.CharField( + required=False, max_length=1024, write_only=True, label=_('Client Secret') + ) + AUTH_OPENID_SHARE_SESSION = serializers.BooleanField(required=False, label=_('Share session')) + AUTH_OPENID_IGNORE_SSL_VERIFICATION = serializers.BooleanField( + required=False, label=_('Ignore ssl verification') + ) + + +class KeycloakSettingSerializer(CommonSettingSerializer): + # OpenID 旧配置参数 (version <= 1.5.8 (discarded)) + AUTH_OPENID_KEYCLOAK = serializers.BooleanField( + label=_("Use Keycloak"), required=False, default=False + ) + AUTH_OPENID_SERVER_URL = serializers.CharField( + required=False, max_length=1024, label=_('Server url') + ) + AUTH_OPENID_REALM_NAME = serializers.CharField( + required=False, max_length=1024, allow_null=True, label=_('Realm name') + ) + + +class OIDCSettingSerializer(KeycloakSettingSerializer): + # OpenID 新配置参数 (version >= 1.5.9) + AUTH_OPENID = serializers.BooleanField(required=False, label=_('Enable OPENID Auth')) + AUTH_OPENID_PROVIDER_ENDPOINT = serializers.CharField( + required=False, max_length=1024, label=_('Provider endpoint') + ) + AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT = serializers.CharField( + required=False, max_length=1024, label=_('Provider auth endpoint') + ) + AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT = serializers.CharField( + required=False, max_length=1024, label=_('Provider token endpoint') + ) + AUTH_OPENID_PROVIDER_JWKS_ENDPOINT = serializers.CharField( + required=False, max_length=1024, label=_('Provider jwks endpoint') + ) + AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT = serializers.CharField( + required=False, max_length=1024, label=_('Provider userinfo endpoint') + ) + AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT = serializers.CharField( + required=False, max_length=1024, label=_('Provider end session endpoint') + ) + AUTH_OPENID_PROVIDER_SIGNATURE_ALG = serializers.CharField( + required=False, max_length=1024, label=_('Provider sign alg') + ) + AUTH_OPENID_PROVIDER_SIGNATURE_KEY = serializers.CharField( + required=False, max_length=1024, allow_null=True, label=_('Provider sign key') + ) + AUTH_OPENID_SCOPES = serializers.CharField(required=False, max_length=1024, label=_('Scopes')) + AUTH_OPENID_ID_TOKEN_MAX_AGE = serializers.IntegerField( + required=False, label=_('Id token max age') + ) + AUTH_OPENID_ID_TOKEN_INCLUDE_CLAIMS = serializers.BooleanField( + required=False, label=_('Id token include claims') + ) + AUTH_OPENID_USE_STATE = serializers.BooleanField(required=False, label=_('Use state')) + AUTH_OPENID_USE_NONCE = serializers.BooleanField(required=False, label=_('Use nonce')) + AUTH_OPENID_ALWAYS_UPDATE_USER = serializers.BooleanField( + required=False, label=_('Always update user') + ) + diff --git a/apps/settings/serializers/auth/radius.py b/apps/settings/serializers/auth/radius.py new file mode 100644 index 000000000..0a956d18a --- /dev/null +++ b/apps/settings/serializers/auth/radius.py @@ -0,0 +1,19 @@ +# coding: utf-8 +# + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'RadiusSettingSerializer', +] + + +class RadiusSettingSerializer(serializers.Serializer): + AUTH_RADIUS = serializers.BooleanField(required=False, label=_('Enable RADIUS Auth')) + RADIUS_SERVER = serializers.CharField(required=False, max_length=1024, label=_('Host')) + RADIUS_PORT = serializers.IntegerField(required=False, label=_('Port')) + RADIUS_SECRET = serializers.CharField( + required=False, max_length=1024, allow_null=True, label=_('Secret'), write_only=True + ) + OTP_IN_RADIUS = serializers.BooleanField(required=False, label=_('OTP in radius')) diff --git a/apps/settings/serializers/auth/sms.py b/apps/settings/serializers/auth/sms.py new file mode 100644 index 000000000..275a8436e --- /dev/null +++ b/apps/settings/serializers/auth/sms.py @@ -0,0 +1,51 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +from common.message.backends.sms import BACKENDS + +__all__ = ['AlibabaSMSSettingSerializer', 'TencentSMSSettingSerializer'] + + +class BaseSMSSettingSerializer(serializers.Serializer): + SMS_ENABLED = serializers.BooleanField(default=False, label=_('Enable SMS')) + SMS_TEST_PHONE = serializers.CharField(max_length=256, required=False, label=_('Test phone')) + + def to_representation(self, instance): + data = super().to_representation(instance) + data['SMS_BACKEND'] = self.fields['SMS_BACKEND'].default + return data + + +class AlibabaSMSSettingSerializer(BaseSMSSettingSerializer): + SMS_BACKEND = serializers.ChoiceField(choices=BACKENDS.choices, default=BACKENDS.ALIBABA) + ALIBABA_ACCESS_KEY_ID = serializers.CharField(max_length=256, required=True, label='AccessKeyId') + ALIBABA_ACCESS_KEY_SECRET = serializers.CharField( + max_length=256, required=False, label='AccessKeySecret', write_only=True) + ALIBABA_SMS_SIGN_AND_TEMPLATES = serializers.DictField( + label=_('Signatures and Templates'), required=True, help_text=_(''' + Filling in JSON Data: + { + "verification_code": { + "sign_name": "", + "template_code": "" + } + } + ''') + ) + + +class TencentSMSSettingSerializer(BaseSMSSettingSerializer): + SMS_BACKEND = serializers.ChoiceField(choices=BACKENDS.choices, default=BACKENDS.TENCENT) + TENCENT_SECRET_ID = serializers.CharField(max_length=256, required=True, label='Secret id') + TENCENT_SECRET_KEY = serializers.CharField(max_length=256, required=False, label='Secret key', write_only=True) + TENCENT_SDKAPPID = serializers.CharField(max_length=256, required=True, label='SDK app id') + TENCENT_SMS_SIGN_AND_TEMPLATES = serializers.DictField( + label=_('Signatures and Templates'), required=True, help_text=_(''' + Filling in JSON Data: + { + "verification_code": { + "sign_name": "", + "template_code": "" + } + } + ''')) diff --git a/apps/settings/serializers/auth/sso.py b/apps/settings/serializers/auth/sso.py new file mode 100644 index 000000000..38481cf2a --- /dev/null +++ b/apps/settings/serializers/auth/sso.py @@ -0,0 +1,17 @@ + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'SSOSettingSerializer', +] + + +class SSOSettingSerializer(serializers.Serializer): + AUTH_SSO = serializers.BooleanField( + required=False, label=_('Enable SSO auth'), + help_text=_("Other service can using SSO token login to JumpServer without password") + ) + AUTH_SSO_AUTHKEY_TTL = serializers.IntegerField( + required=False, label=_('SSO auth key TTL'), help_text=_("Unit: second") + ) diff --git a/apps/settings/serializers/auth/wecom.py b/apps/settings/serializers/auth/wecom.py new file mode 100644 index 000000000..ceb83aa85 --- /dev/null +++ b/apps/settings/serializers/auth/wecom.py @@ -0,0 +1,11 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = ['WeComSettingSerializer'] + + +class WeComSettingSerializer(serializers.Serializer): + WECOM_CORPID = serializers.CharField(max_length=256, required=True, label='corpid') + WECOM_AGENTID = serializers.CharField(max_length=256, required=True, label='agentid') + WECOM_SECRET = serializers.CharField(max_length=256, required=False, label='secret', write_only=True) + AUTH_WECOM = serializers.BooleanField(default=False, label=_('Enable WeCom Auth')) \ No newline at end of file diff --git a/apps/settings/serializers/basic.py b/apps/settings/serializers/basic.py new file mode 100644 index 000000000..82b7f83dd --- /dev/null +++ b/apps/settings/serializers/basic.py @@ -0,0 +1,22 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + + +class BasicSettingSerializer(serializers.Serializer): + SITE_URL = serializers.URLField( + required=True, label=_("Site url"), + help_text=_('eg: http://dev.jumpserver.org:8080') + ) + USER_GUIDE_URL = serializers.URLField( + required=False, allow_blank=True, allow_null=True, label=_("User guide url"), + help_text=_('User first login update profile done redirect to it') + ) + FORGOT_PASSWORD_URL = serializers.URLField( + required=False, allow_blank=True, allow_null=True, label=_("Forgot password url"), + help_text=_('The forgot password url on login page, If you use ' + 'ldap or cas external authentication, you can set it') + ) + GLOBAL_ORG_DISPLAY_NAME = serializers.CharField( + required=False, max_length=1024, allow_blank=True, allow_null=True, label=_("Global organization name"), + help_text=_('The name of global organization to display') + ) diff --git a/apps/settings/serializers/cleaning.py b/apps/settings/serializers/cleaning.py new file mode 100644 index 000000000..182a97eaa --- /dev/null +++ b/apps/settings/serializers/cleaning.py @@ -0,0 +1,22 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = ['CleaningSerializer'] + + +class CleaningSerializer(serializers.Serializer): + LOGIN_LOG_KEEP_DAYS = serializers.IntegerField( + label=_("Login log keep days"), help_text=_("Unit: day") + ) + TASK_LOG_KEEP_DAYS = serializers.IntegerField( + label=_("Task log keep days"), help_text=_("Unit: day") + ) + OPERATE_LOG_KEEP_DAYS = serializers.IntegerField( + label=_("Operate log keep days"), help_text=_("Unit: day") + ) + FTP_LOG_KEEP_DAYS = serializers.IntegerField( + label=_("FTP log keep days"), help_text=_("Unit: day") + ) + CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS = serializers.IntegerField( + label=_("Cloud sync record keep days"), help_text=_("Unit: day") + ) diff --git a/apps/settings/serializers/email.py b/apps/settings/serializers/email.py index 6d033f6aa..2ad7f84e6 100644 --- a/apps/settings/serializers/email.py +++ b/apps/settings/serializers/email.py @@ -1,9 +1,10 @@ # coding: utf-8 # +from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers -__all__ = ['MailTestSerializer'] +__all__ = ['MailTestSerializer', 'EmailSettingSerializer', 'EmailContentSettingSerializer'] class MailTestSerializer(serializers.Serializer): @@ -15,3 +16,56 @@ class MailTestSerializer(serializers.Serializer): EMAIL_RECIPIENT = serializers.CharField(required=False, allow_blank=True) EMAIL_USE_SSL = serializers.BooleanField(default=False) EMAIL_USE_TLS = serializers.BooleanField(default=False) + + +class EmailSettingSerializer(serializers.Serializer): + # encrypt_fields 现在使用 write_only 来判断了 + + EMAIL_HOST = serializers.CharField(max_length=1024, required=True, label=_("SMTP host")) + EMAIL_PORT = serializers.CharField(max_length=5, required=True, label=_("SMTP port")) + EMAIL_HOST_USER = serializers.CharField(max_length=128, required=True, label=_("SMTP account")) + EMAIL_HOST_PASSWORD = serializers.CharField( + max_length=1024, write_only=True, required=False, label=_("SMTP password"), + help_text=_("Tips: Some provider use token except password") + ) + EMAIL_FROM = serializers.CharField( + max_length=128, allow_blank=True, required=False, label=_('Send user'), + help_text=_('Tips: Send mail account, default SMTP account as the send account') + ) + EMAIL_RECIPIENT = serializers.CharField( + max_length=128, allow_blank=True, required=False, label=_('Test recipient'), + help_text=_('Tips: Used only as a test mail recipient') + ) + EMAIL_USE_SSL = serializers.BooleanField( + required=False, label=_('Use SSL'), + help_text=_('If SMTP port is 465, may be select') + ) + EMAIL_USE_TLS = serializers.BooleanField( + required=False, label=_("Use TLS"), + help_text=_('If SMTP port is 587, may be select') + ) + EMAIL_SUBJECT_PREFIX = serializers.CharField( + max_length=1024, required=True, label=_('Subject prefix') + ) + + +class EmailContentSettingSerializer(serializers.Serializer): + EMAIL_CUSTOM_USER_CREATED_SUBJECT = serializers.CharField( + max_length=1024, allow_blank=True, required=False, + label=_('Create user email subject'), + help_text=_('Tips: When creating a user, send the subject of the email (eg:Create account successfully)') + ) + EMAIL_CUSTOM_USER_CREATED_HONORIFIC = serializers.CharField( + max_length=1024, allow_blank=True, required=False, + label=_('Create user honorific'), + help_text=_('Tips: When creating a user, send the honorific of the email (eg:Hello)') + ) + EMAIL_CUSTOM_USER_CREATED_BODY = serializers.CharField( + max_length=4096, allow_blank=True, required=False, + label=_('Create user email content'), + help_text=_('Tips:When creating a user, send the content of the email') + ) + EMAIL_CUSTOM_USER_CREATED_SIGNATURE = serializers.CharField( + max_length=512, allow_blank=True, required=False, label=_('Signature'), + help_text=_('Tips: Email signature (eg:jumpserver)') + ) diff --git a/apps/settings/serializers/ldap.py b/apps/settings/serializers/ldap.py deleted file mode 100644 index 1ccc02c26..000000000 --- a/apps/settings/serializers/ldap.py +++ /dev/null @@ -1,34 +0,0 @@ -# coding: utf-8 -# - -from django.utils.translation import ugettext_lazy as _ -from rest_framework import serializers - -__all__ = [ - 'LDAPTestConfigSerializer', 'LDAPUserSerializer', 'LDAPTestLoginSerializer' -] - - -class LDAPTestConfigSerializer(serializers.Serializer): - AUTH_LDAP_SERVER_URI = serializers.CharField(max_length=1024) - AUTH_LDAP_BIND_DN = serializers.CharField(max_length=1024, required=False, allow_blank=True) - AUTH_LDAP_BIND_PASSWORD = serializers.CharField(required=False, allow_blank=True) - AUTH_LDAP_SEARCH_OU = serializers.CharField() - AUTH_LDAP_SEARCH_FILTER = serializers.CharField() - AUTH_LDAP_USER_ATTR_MAP = serializers.CharField() - AUTH_LDAP_START_TLS = serializers.BooleanField(required=False) - AUTH_LDAP = serializers.BooleanField(required=False) - - -class LDAPTestLoginSerializer(serializers.Serializer): - username = serializers.CharField(max_length=1024, required=True) - password = serializers.CharField(max_length=2014, required=True) - - -class LDAPUserSerializer(serializers.Serializer): - id = serializers.CharField() - username = serializers.CharField() - name = serializers.CharField() - email = serializers.CharField() - existing = serializers.BooleanField(read_only=True) - diff --git a/apps/settings/serializers/other.py b/apps/settings/serializers/other.py new file mode 100644 index 000000000..f8ebaeceb --- /dev/null +++ b/apps/settings/serializers/other.py @@ -0,0 +1,28 @@ +from abc import ABCMeta + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + + +class OtherSettingSerializer(serializers.Serializer): + EMAIL_SUFFIX = serializers.CharField( + required=False, max_length=1024, label=_("Email suffix"), + help_text=_('This is used by default if no email is returned during SSO authentication') + ) + TICKETS_ENABLED = serializers.BooleanField(required=False, default=True, label=_("Enable tickets")) + + OTP_ISSUER_NAME = serializers.CharField( + required=False, max_length=1024, label=_('OTP issuer name'), + ) + OTP_VALID_WINDOW = serializers.IntegerField(label=_("OTP valid window")) + + PERIOD_TASK_ENABLED = serializers.BooleanField(required=False, label=_("Enable period task")) + WINDOWS_SSH_DEFAULT_SHELL = serializers.CharField( + required=False, max_length=1024, label=_('Ansible windows default shell') + ) + + PERM_SINGLE_ASSET_TO_UNGROUP_NODE = serializers.BooleanField( + required=False, label=_("Perm single to ungroup node") + ) + + diff --git a/apps/settings/serializers/security.py b/apps/settings/serializers/security.py new file mode 100644 index 000000000..5749a5c8f --- /dev/null +++ b/apps/settings/serializers/security.py @@ -0,0 +1,115 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + + +class SecurityPasswordRuleSerializer(serializers.Serializer): + SECURITY_PASSWORD_MIN_LENGTH = serializers.IntegerField( + min_value=6, max_value=30, required=True, + label=_('Password minimum length') + ) + SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH = serializers.IntegerField( + min_value=6, max_value=30, required=True, + label=_('Admin user password minimum length') + ) + SECURITY_PASSWORD_UPPER_CASE = serializers.BooleanField( + required=False, label=_('Must contain capital') + ) + SECURITY_PASSWORD_LOWER_CASE = serializers.BooleanField(required=False, label=_('Must contain lowercase')) + SECURITY_PASSWORD_NUMBER = serializers.BooleanField(required=False, label=_('Must contain numeric')) + SECURITY_PASSWORD_SPECIAL_CHAR = serializers.BooleanField(required=False, label=_('Must contain special')) + + +class SecurityAuthSerializer(serializers.Serializer): + SECURITY_MFA_AUTH = serializers.ChoiceField( + choices=( + [0, _('Disable')], + [1, _('All users')], + [2, _('Only admin users')], + ), + required=False, label=_("Global MFA auth") + ) + SECURITY_LOGIN_LIMIT_COUNT = serializers.IntegerField( + min_value=3, max_value=99999, + label=_('Limit the number of login failures') + ) + SECURITY_LOGIN_LIMIT_TIME = serializers.IntegerField( + min_value=5, max_value=99999, required=True, + label=_('Block logon interval'), + help_text=_( + 'Unit: minute, If the user has failed to log in for a limited number of times, ' + 'no login is allowed during this time interval.' + ) + ) + SECURITY_PASSWORD_EXPIRATION_TIME = serializers.IntegerField( + min_value=1, max_value=99999, required=True, + label=_('User password expiration'), + help_text=_( + 'Unit: day, If the user does not update the password during the time, ' + 'the user password will expire failure;The password expiration reminder mail will be ' + 'automatic sent to the user by system within 5 days (daily) before the password expires' + ) + ) + OLD_PASSWORD_HISTORY_LIMIT_COUNT = serializers.IntegerField( + min_value=0, max_value=99999, required=True, + label=_('Number of repeated historical passwords'), + help_text=_( + 'Tip: When the user resets the password, it cannot be ' + 'the previous n historical passwords of the user' + ) + ) + USER_LOGIN_SINGLE_MACHINE_ENABLED = serializers.BooleanField( + required=False, default=False, label=_("Only single device login"), + help_text=_("Next device login, pre login will be logout") + ) + ONLY_ALLOW_EXIST_USER_AUTH = serializers.BooleanField( + required=False, default=False, label=_("Only exist user login"), + help_text=_("If enable, CAS、OIDC auth will be failed, if user not exist yet") + ) + ONLY_ALLOW_AUTH_FROM_SOURCE = serializers.BooleanField( + required=False, default=False, label=_("Only from source login"), + help_text=_("If enable, CAS、OIDC auth will be failed, if user not exist yet") + ) + SECURITY_MFA_VERIFY_TTL = serializers.IntegerField(label=_("MFA verify TTL"), help_text=_("Unit: second")) + SECURITY_LOGIN_CAPTCHA_ENABLED = serializers.BooleanField( + required=False, default=True, + label=_("Enable Login captcha") + ) + + +class SecuritySettingSerializer(SecurityPasswordRuleSerializer, SecurityAuthSerializer): + SECURITY_SERVICE_ACCOUNT_REGISTRATION = serializers.BooleanField( + required=True, label=_('Enable terminal register'), + help_text=_("Allow terminal register, after all terminal setup, you should disable this for security") + ) + SECURITY_WATERMARK_ENABLED = serializers.BooleanField( + required=True, label=_('Replay watermark'), + help_text=_('Enabled, the session replay contains watermark information') + ) + SECURITY_MAX_IDLE_TIME = serializers.IntegerField( + min_value=1, max_value=99999, required=False, + label=_('Connection max idle time'), + help_text=_('If idle time more than it, disconnect connection Unit: minute') + ) + SECURITY_LUNA_REMEMBER_AUTH = serializers.BooleanField( + label=_("Remember manual auth") + ) + CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED = serializers.BooleanField( + label=_("Enable change auth secure mode") + ) + SECURITY_INSECURE_COMMAND = serializers.BooleanField( + required=False, label=_('Insecure command alert') + ) + SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER = serializers.CharField( + max_length=8192, required=False, allow_blank=True, label=_('Email recipient'), + help_text=_('Multiple user using , split') + ) + SECURITY_COMMAND_EXECUTION = serializers.BooleanField( + required=False, label=_('Batch command execution'), + help_text=_('Allow user run batch command or not using ansible') + ) + SECURITY_SESSION_SHARE = serializers.BooleanField( + required=True, label=_('Session share'), + help_text=_("Enabled, Allows user active session to be shared with other users") + ) + + diff --git a/apps/settings/serializers/settings.py b/apps/settings/serializers/settings.py index 6c0dadb14..7baa19196 100644 --- a/apps/settings/serializers/settings.py +++ b/apps/settings/serializers/settings.py @@ -1,246 +1,42 @@ # coding: utf-8 -from django.utils.translation import ugettext_lazy as _ -from rest_framework import serializers +from .basic import BasicSettingSerializer +from .other import OtherSettingSerializer +from .email import EmailSettingSerializer, EmailContentSettingSerializer +from .auth import ( + LDAPSettingSerializer, OIDCSettingSerializer, KeycloakSettingSerializer, + CASSettingSerializer, RadiusSettingSerializer, FeiShuSettingSerializer, + WeComSettingSerializer, DingTalkSettingSerializer, AlibabaSMSSettingSerializer, + TencentSMSSettingSerializer, +) +from .terminal import TerminalSettingSerializer +from .security import SecuritySettingSerializer +from .cleaning import CleaningSerializer + __all__ = [ - 'BasicSettingSerializer', 'EmailSettingSerializer', 'EmailContentSettingSerializer', - 'LDAPSettingSerializer', 'TerminalSettingSerializer', 'SecuritySettingSerializer', - 'SettingsSerializer', 'WeComSettingSerializer', 'DingTalkSettingSerializer', - 'FeiShuSettingSerializer', + 'SettingsSerializer', ] -class BasicSettingSerializer(serializers.Serializer): - SITE_URL = serializers.URLField( - required=True, label=_("Site url"), - help_text=_('eg: http://dev.jumpserver.org:8080') - ) - - USER_GUIDE_URL = serializers.URLField( - required=False, allow_blank=True, allow_null=True, label=_("User guide url"), - help_text=_('User first login update profile done redirect to it') - ) - FORGOT_PASSWORD_URL = serializers.URLField( - required=False, allow_blank=True, allow_null=True, label=_("Forgot password url"), - help_text=_('The forgot password url on login page, If you use ' - 'ldap or cas external authentication, you can set it') - ) - GLOBAL_ORG_DISPLAY_NAME = serializers.CharField( - required=False, max_length=1024, allow_blank=True, allow_null=True, label=_("Global organization name"), - help_text=_('The name of global organization to display') - ) - - -class EmailSettingSerializer(serializers.Serializer): - # encrypt_fields 现在使用 write_only 来判断了 - - EMAIL_HOST = serializers.CharField(max_length=1024, required=True, label=_("SMTP host")) - EMAIL_PORT = serializers.CharField(max_length=5, required=True, label=_("SMTP port")) - EMAIL_HOST_USER = serializers.CharField(max_length=128, required=True, label=_("SMTP account")) - EMAIL_HOST_PASSWORD = serializers.CharField( - max_length=1024, write_only=True, required=False, label=_("SMTP password"), - help_text=_("Tips: Some provider use token except password") - ) - EMAIL_FROM = serializers.CharField( - max_length=128, allow_blank=True, required=False, label=_('Send user'), - help_text=_('Tips: Send mail account, default SMTP account as the send account') - ) - EMAIL_RECIPIENT = serializers.CharField( - max_length=128, allow_blank=True, required=False, label=_('Test recipient'), - help_text=_('Tips: Used only as a test mail recipient') - ) - EMAIL_USE_SSL = serializers.BooleanField( - required=False, label=_('Use SSL'), - help_text=_('If SMTP port is 465, may be select') - ) - EMAIL_USE_TLS = serializers.BooleanField( - required=False, label=_("Use TLS"), - help_text=_('If SMTP port is 587, may be select') - ) - EMAIL_SUBJECT_PREFIX = serializers.CharField( - max_length=1024, required=True, label=_('Subject prefix') - ) - - -class EmailContentSettingSerializer(serializers.Serializer): - EMAIL_CUSTOM_USER_CREATED_SUBJECT = serializers.CharField( - max_length=1024, allow_blank=True, required=False, - label=_('Create user email subject'), - help_text=_('Tips: When creating a user, send the subject of the email (eg:Create account successfully)') - ) - EMAIL_CUSTOM_USER_CREATED_HONORIFIC = serializers.CharField( - max_length=1024, allow_blank=True, required=False, - label=_('Create user honorific'), - help_text=_('Tips: When creating a user, send the honorific of the email (eg:Hello)') - ) - EMAIL_CUSTOM_USER_CREATED_BODY = serializers.CharField( - max_length=4096, allow_blank=True, required=False, - label=_('Create user email content'), - help_text=_('Tips:When creating a user, send the content of the email') - ) - EMAIL_CUSTOM_USER_CREATED_SIGNATURE = serializers.CharField( - max_length=512, allow_blank=True, required=False, label=_('Signature'), - help_text=_('Tips: Email signature (eg:jumpserver)') - ) - - -class LDAPSettingSerializer(serializers.Serializer): - # encrypt_fields 现在使用 write_only 来判断了 - - AUTH_LDAP_SERVER_URI = serializers.CharField( - required=True, max_length=1024, label=_('LDAP server'), help_text=_('eg: ldap://localhost:389') - ) - AUTH_LDAP_BIND_DN = serializers.CharField(required=False, max_length=1024, label=_('Bind DN')) - AUTH_LDAP_BIND_PASSWORD = serializers.CharField(max_length=1024, write_only=True, required=False, label=_('Password')) - AUTH_LDAP_SEARCH_OU = serializers.CharField( - max_length=1024, allow_blank=True, required=False, label=_('User OU'), - help_text=_('Use | split multi OUs') - ) - AUTH_LDAP_SEARCH_FILTER = serializers.CharField( - max_length=1024, required=True, label=_('User search filter'), - help_text=_('Choice may be (cn|uid|sAMAccountName)=%(user)s)') - ) - AUTH_LDAP_USER_ATTR_MAP = serializers.DictField( - required=True, label=_('User attr map'), - help_text=_('User attr map present how to map LDAP user attr to jumpserver, username,name,email is jumpserver attr') - ) - AUTH_LDAP = serializers.BooleanField(required=False, label=_('Enable LDAP auth')) - - -class TerminalSettingSerializer(serializers.Serializer): - SORT_BY_CHOICES = ( - ('hostname', _('Hostname')), - ('ip', _('IP')) - ) - - PAGE_SIZE_CHOICES = ( - ('all', _('All')), - ('auto', _('Auto')), - ('10', '10'), - ('15', '15'), - ('25', '25'), - ('50', '50'), - ) - TERMINAL_PASSWORD_AUTH = serializers.BooleanField(required=False, label=_('Password auth')) - TERMINAL_PUBLIC_KEY_AUTH = serializers.BooleanField( - required=False, label=_('Public key auth'), - help_text=_('Tips: If use other auth method, like AD/LDAP, you should disable this to ' - 'avoid being able to log in after deleting') - ) - TERMINAL_ASSET_LIST_SORT_BY = serializers.ChoiceField(SORT_BY_CHOICES, required=False, label=_('List sort by')) - TERMINAL_ASSET_LIST_PAGE_SIZE = serializers.ChoiceField(PAGE_SIZE_CHOICES, required=False, label=_('List page size')) - TERMINAL_SESSION_KEEP_DURATION = serializers.IntegerField( - min_value=1, max_value=99999, required=True, label=_('Session keep duration'), - help_text=_('Units: days, Session, record, command will be delete if more than duration, only in database') - ) - TERMINAL_TELNET_REGEX = serializers.CharField(allow_blank=True, max_length=1024, required=False, label=_('Telnet login regex')) - TERMINAL_RDP_ADDR = serializers.CharField( - required=False, label=_("RDP address"), - max_length=1024, - allow_blank=True, - help_text=_('RDP visit address, eg: dev.jumpserver.org:3389') - ) - - -class SecuritySettingSerializer(serializers.Serializer): - SECURITY_MFA_AUTH = serializers.ChoiceField( - choices=( - [0, _('Disable')], - [1, _('All users')], - [2, _('Only admin users')], - ), - required=False, label=_("Global MFA auth") - ) - SECURITY_COMMAND_EXECUTION = serializers.BooleanField( - required=False, label=_('Batch command execution'), - help_text=_('Allow user run batch command or not using ansible') - ) - SECURITY_SERVICE_ACCOUNT_REGISTRATION = serializers.BooleanField( - required=True, label=_('Enable terminal register'), - help_text=_("Allow terminal register, after all terminal setup, you should disable this for security") - ) - SECURITY_WATERMARK_ENABLED = serializers.BooleanField( - required=True, label=_('Replay watermark'), - help_text=_('Enabled, the session replay contains watermark information') - ) - SECURITY_LOGIN_LIMIT_COUNT = serializers.IntegerField( - min_value=3, max_value=99999, - label=_('Limit the number of login failures') - ) - SECURITY_LOGIN_LIMIT_TIME = serializers.IntegerField( - min_value=5, max_value=99999, required=True, - label=_('Block logon interval'), - help_text=_('Tip: (unit/minute) if the user has failed to log in for a limited number of times, no login is allowed during this time interval.') - ) - SECURITY_MAX_IDLE_TIME = serializers.IntegerField( - min_value=1, max_value=99999, required=False, - label=_('Connection max idle time'), - help_text=_('If idle time more than it, disconnect connection Unit: minute') - ) - SECURITY_PASSWORD_EXPIRATION_TIME = serializers.IntegerField( - min_value=1, max_value=99999, required=True, - label=_('User password expiration'), - help_text=_('Tip: (unit: day) If the user does not update the password during the time, the user password will expire failure;The password expiration reminder mail will be automatic sent to the user by system within 5 days (daily) before the password expires') - ) - OLD_PASSWORD_HISTORY_LIMIT_COUNT = serializers.IntegerField( - min_value=0, max_value=99999, required=True, - label=_('Number of repeated historical passwords'), - help_text=_('Tip: When the user resets the password, it cannot be the previous n historical passwords of the user') - ) - SECURITY_PASSWORD_MIN_LENGTH = serializers.IntegerField( - min_value=6, max_value=30, required=True, - label=_('Password minimum length') - ) - SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH = serializers.IntegerField( - min_value=6, max_value=30, required=True, - label=_('Admin user password minimum length') - ) - SECURITY_PASSWORD_UPPER_CASE = serializers.BooleanField( - required=False, label=_('Must contain capital') - ) - SECURITY_PASSWORD_LOWER_CASE = serializers.BooleanField(required=False, label=_('Must contain lowercase')) - SECURITY_PASSWORD_NUMBER = serializers.BooleanField(required=False, label=_('Must contain numeric')) - SECURITY_PASSWORD_SPECIAL_CHAR = serializers.BooleanField(required=False, label=_('Must contain special')) - SECURITY_INSECURE_COMMAND = serializers.BooleanField(required=False, label=_('Insecure command alert')) - SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER = serializers.CharField( - max_length=8192, required=False, allow_blank=True, label=_('Email recipient'), - help_text=_('Multiple user using , split') - ) - - -class WeComSettingSerializer(serializers.Serializer): - WECOM_CORPID = serializers.CharField(max_length=256, required=True, label='corpid') - WECOM_AGENTID = serializers.CharField(max_length=256, required=True, label='agentid') - WECOM_SECRET = serializers.CharField(max_length=256, required=False, label='secret', write_only=True) - AUTH_WECOM = serializers.BooleanField(default=False, label=_('Enable WeCom Auth')) - - -class DingTalkSettingSerializer(serializers.Serializer): - DINGTALK_AGENTID = serializers.CharField(max_length=256, required=True, label='AgentId') - DINGTALK_APPKEY = serializers.CharField(max_length=256, required=True, label='AppKey') - DINGTALK_APPSECRET = serializers.CharField(max_length=256, required=False, label='AppSecret', write_only=True) - AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('Enable DingTalk Auth')) - - -class FeiShuSettingSerializer(serializers.Serializer): - FEISHU_APP_ID = serializers.CharField(max_length=256, required=True, label='App ID') - FEISHU_APP_SECRET = serializers.CharField(max_length=256, required=False, label='App Secret', write_only=True) - AUTH_FEISHU = serializers.BooleanField(default=False, label=_('Enable FeiShu Auth')) - - class SettingsSerializer( BasicSettingSerializer, - EmailSettingSerializer, - EmailContentSettingSerializer, LDAPSettingSerializer, TerminalSettingSerializer, SecuritySettingSerializer, WeComSettingSerializer, DingTalkSettingSerializer, FeiShuSettingSerializer, + EmailSettingSerializer, + EmailContentSettingSerializer, + OtherSettingSerializer, + OIDCSettingSerializer, + KeycloakSettingSerializer, + CASSettingSerializer, + RadiusSettingSerializer, + CleaningSerializer, + AlibabaSMSSettingSerializer, + TencentSMSSettingSerializer, ): - # encrypt_fields 现在使用 write_only 来判断了 pass - diff --git a/apps/settings/serializers/sms.py b/apps/settings/serializers/sms.py new file mode 100644 index 000000000..fa274a52a --- /dev/null +++ b/apps/settings/serializers/sms.py @@ -0,0 +1,7 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + + +class SMSBackendSerializer(serializers.Serializer): + name = serializers.CharField(max_length=256, required=True, label=_('Name')) + label = serializers.CharField(max_length=256, required=True, label=_('Label')) diff --git a/apps/settings/serializers/terminal.py b/apps/settings/serializers/terminal.py new file mode 100644 index 000000000..0410a7517 --- /dev/null +++ b/apps/settings/serializers/terminal.py @@ -0,0 +1,42 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + + +class TerminalSettingSerializer(serializers.Serializer): + SORT_BY_CHOICES = ( + ('hostname', _('Hostname')), + ('ip', _('IP')) + ) + + PAGE_SIZE_CHOICES = ( + ('all', _('All')), + ('auto', _('Auto')), + ('10', '10'), + ('15', '15'), + ('25', '25'), + ('50', '50'), + ) + TERMINAL_PASSWORD_AUTH = serializers.BooleanField(required=False, label=_('Password auth')) + TERMINAL_PUBLIC_KEY_AUTH = serializers.BooleanField( + required=False, label=_('Public key auth'), + help_text=_('Tips: If use other auth method, like AD/LDAP, you should disable this to ' + 'avoid being able to log in after deleting') + ) + TERMINAL_ASSET_LIST_SORT_BY = serializers.ChoiceField(SORT_BY_CHOICES, required=False, label=_('List sort by')) + TERMINAL_ASSET_LIST_PAGE_SIZE = serializers.ChoiceField(PAGE_SIZE_CHOICES, required=False, + label=_('List page size')) + TERMINAL_SESSION_KEEP_DURATION = serializers.IntegerField( + min_value=1, max_value=99999, required=True, label=_('Session keep duration'), + help_text=_('Unit: days, Session, record, command will be delete if more than duration, only in database') + ) + TERMINAL_TELNET_REGEX = serializers.CharField( + allow_blank=True, max_length=1024, required=False, label=_('Telnet login regex'), + help_text=_("The login success message varies with devices. " + "if you cannot log in to the device through Telnet, set this parameter") + ) + TERMINAL_RDP_ADDR = serializers.CharField( + required=False, label=_("RDP address"), max_length=1024, allow_blank=True, + help_text=_('RDP visit address, eg: dev.jumpserver.org:3389') + ) + + XRDP_ENABLED = serializers.BooleanField(label=_("Enable XRDP")) diff --git a/apps/settings/urls/api_urls.py b/apps/settings/urls/api_urls.py index bd423611f..22825f4e8 100644 --- a/apps/settings/urls/api_urls.py +++ b/apps/settings/urls/api_urls.py @@ -16,6 +16,9 @@ urlpatterns = [ path('wecom/testing/', api.WeComTestingAPI.as_view(), name='wecom-testing'), path('dingtalk/testing/', api.DingTalkTestingAPI.as_view(), name='dingtalk-testing'), path('feishu/testing/', api.FeiShuTestingAPI.as_view(), name='feishu-testing'), + path('alibaba/testing/', api.AlibabaSMSTestingAPI.as_view(), name='alibaba-sms-testing'), + path('tencent/testing/', api.TencentSMSTestingAPI.as_view(), name='tencent-sms-testing'), + path('sms/backend/', api.SMSBackendAPI.as_view(), name='sms-backend'), path('setting/', api.SettingsApi.as_view(), name='settings-setting'), path('public/', api.PublicSettingApi.as_view(), name='public-setting'), diff --git a/apps/templates/resource_download.html b/apps/templates/resource_download.html new file mode 100644 index 000000000..da8242a12 --- /dev/null +++ b/apps/templates/resource_download.html @@ -0,0 +1,36 @@ +{% extends '_without_nav_base.html' %} +{% block body %} + + + +{% endblock %} diff --git a/apps/terminal/api/__init__.py b/apps/terminal/api/__init__.py index e6a3b3885..fec6da11e 100644 --- a/apps/terminal/api/__init__.py +++ b/apps/terminal/api/__init__.py @@ -6,3 +6,4 @@ from .command import * from .task import * from .storage import * from .status import * +from .sharing import * diff --git a/apps/terminal/api/sharing.py b/apps/terminal/api/sharing.py new file mode 100644 index 000000000..3fe5ca45c --- /dev/null +++ b/apps/terminal/api/sharing.py @@ -0,0 +1,79 @@ +from rest_framework.exceptions import MethodNotAllowed, ValidationError +from rest_framework.decorators import action +from rest_framework.response import Response +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from common.permissions import IsAppUser, IsSuperUser +from common.const.http import PATCH +from orgs.mixins.api import OrgModelViewSet +from .. import serializers, models + +__all__ = ['SessionSharingViewSet', 'SessionJoinRecordsViewSet'] + + +class SessionSharingViewSet(OrgModelViewSet): + serializer_class = serializers.SessionSharingSerializer + permission_classes = (IsAppUser | IsSuperUser, ) + search_fields = ('session', 'creator', 'is_active', 'expired_time') + filterset_fields = search_fields + model = models.SessionSharing + + def get_permissions(self): + if self.request.method.lower() in ['post']: + self.permission_classes = (IsAppUser,) + return super().get_permissions() + + def create(self, request, *args, **kwargs): + if not settings.SECURITY_SESSION_SHARE: + detail = _('Secure session sharing settings is disabled') + raise MethodNotAllowed(self.action, detail=detail) + return super().create(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(self.action) + + +class SessionJoinRecordsViewSet(OrgModelViewSet): + serializer_class = serializers.SessionJoinRecordSerializer + permission_classes = (IsAppUser | IsSuperUser, ) + search_fields = ( + 'sharing', 'session', 'joiner', 'date_joined', 'date_left', + 'login_from', 'is_success', 'is_finished' + ) + filterset_fields = search_fields + model = models.SessionJoinRecord + + def get_permissions(self): + if self.request.method.lower() in ['post']: + self.permission_classes = (IsAppUser,) + return super().get_permissions() + + def create(self, request, *args, **kwargs): + try: + response = super().create(request, *args, **kwargs) + except ValidationError as e: + error = e.args[0] if e.args else '' + response = Response( + data={'error': str(error)}, status=e.status_code + ) + return response + + def perform_create(self, serializer): + instance = serializer.save() + self.can_join(instance) + + @staticmethod + def can_join(instance): + can_join, reason = instance.can_join() + if not can_join: + instance.join_failed(reason=reason) + raise ValidationError(reason) + + @action(methods=[PATCH], detail=True) + def finished(self, request, *args, **kwargs): + instance = self.get_object() + instance.finished() + return Response(data={'msg': 'ok'}) + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(self.action) diff --git a/apps/terminal/migrations/0040_sessionjoinrecord_sessionsharing.py b/apps/terminal/migrations/0040_sessionjoinrecord_sessionsharing.py new file mode 100644 index 000000000..a2e0933a8 --- /dev/null +++ b/apps/terminal/migrations/0040_sessionjoinrecord_sessionsharing.py @@ -0,0 +1,59 @@ +# Generated by Django 3.1.12 on 2021-09-07 09:20 + +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), + ('terminal', '0039_auto_20210805_1552'), + ] + + operations = [ + migrations.CreateModel( + name='SessionSharing', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('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')), + ('verify_code', models.CharField(max_length=16, verbose_name='Verify code')), + ('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Active')), + ('expired_time', models.IntegerField(db_index=True, default=0, verbose_name='Expired time (min)')), + ('creator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Creator')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='terminal.session', verbose_name='Session')), + ], + options={ + 'ordering': ('-date_created',), + }, + ), + migrations.CreateModel( + name='SessionJoinRecord', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('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')), + ('verify_code', models.CharField(max_length=16, verbose_name='Verify code')), + ('date_joined', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Date joined')), + ('date_left', models.DateTimeField(db_index=True, null=True, verbose_name='Date left')), + ('remote_addr', models.CharField(blank=True, db_index=True, max_length=128, null=True, verbose_name='Remote addr')), + ('login_from', models.CharField(choices=[('ST', 'SSH Terminal'), ('RT', 'RDP Terminal'), ('WT', 'Web Terminal')], default='WT', max_length=2, verbose_name='Login from')), + ('is_success', models.BooleanField(db_index=True, default=True, verbose_name='Success')), + ('reason', models.CharField(blank=True, default='-', max_length=1024, null=True, verbose_name='Reason')), + ('is_finished', models.BooleanField(db_index=True, default=False, verbose_name='Finished')), + ('joiner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Joiner')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='terminal.session', verbose_name='Session')), + ('sharing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='terminal.sessionsharing', verbose_name='Session sharing')), + ], + options={ + 'ordering': ('-date_joined',), + }, + ), + ] diff --git a/apps/terminal/models/__init__.py b/apps/terminal/models/__init__.py index 1de5fd31e..ef5c759a6 100644 --- a/apps/terminal/models/__init__.py +++ b/apps/terminal/models/__init__.py @@ -4,3 +4,4 @@ from .status import * from .storage import * from .task import * from .terminal import * +from .sharing import * diff --git a/apps/terminal/models/sharing.py b/apps/terminal/models/sharing.py new file mode 100644 index 000000000..46b4eb1e8 --- /dev/null +++ b/apps/terminal/models/sharing.py @@ -0,0 +1,124 @@ +from django.db import models +import datetime +from common.mixins import CommonModelMixin +from orgs.mixins.models import OrgModelMixin +from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone +from .session import Session + + +__all__ = ['SessionSharing', 'SessionJoinRecord'] + + +class SessionSharing(CommonModelMixin, OrgModelMixin): + session = models.ForeignKey( + 'terminal.Session', on_delete=models.CASCADE, verbose_name=_('Session') + ) + # creator / created_by + creator = models.ForeignKey( + 'users.User', on_delete=models.CASCADE, blank=True, null=True, + verbose_name=_('Creator') + ) + verify_code = models.CharField(max_length=16, verbose_name=_('Verify code')) + is_active = models.BooleanField( + default=True, verbose_name=_('Active'), db_index=True + ) + expired_time = models.IntegerField( + default=0, verbose_name=_('Expired time (min)'), db_index=True + ) + + class Meta: + ordering = ('-date_created', ) + + def __str__(self): + return 'Creator: {}'.format(self.creator) + + @property + def date_expired(self): + return self.date_created + datetime.timedelta(minutes=self.expired_time) + + @property + def is_expired(self): + if timezone.now() > self.date_expired: + return False + return True + + def can_join(self): + if not self.is_active: + return False, _('Link not active') + if not self.is_expired: + return False, _('Link expired') + return True, '' + + +class SessionJoinRecord(CommonModelMixin, OrgModelMixin): + LOGIN_FROM = Session.LOGIN_FROM + + session = models.ForeignKey( + 'terminal.Session', on_delete=models.CASCADE, verbose_name=_('Session') + ) + verify_code = models.CharField(max_length=16, verbose_name=_('Verify code')) + sharing = models.ForeignKey( + SessionSharing, on_delete=models.CASCADE, + verbose_name=_('Session sharing') + ) + joiner = models.ForeignKey( + 'users.User', on_delete=models.CASCADE, blank=True, null=True, + verbose_name=_('Joiner') + ) + date_joined = models.DateTimeField( + auto_now_add=True, verbose_name=_("Date joined"), db_index=True, + ) + date_left = models.DateTimeField( + verbose_name=_("Date left"), null=True, db_index=True + ) + remote_addr = models.CharField( + max_length=128, verbose_name=_("Remote addr"), blank=True, null=True, + db_index=True + ) + login_from = models.CharField( + max_length=2, choices=LOGIN_FROM.choices, default="WT", + verbose_name=_("Login from") + ) + is_success = models.BooleanField( + default=True, db_index=True, verbose_name=_('Success') + ) + reason = models.CharField( + max_length=1024, default='-', blank=True, null=True, + verbose_name=_('Reason') + ) + is_finished = models.BooleanField( + default=False, db_index=True, verbose_name=_('Finished') + ) + + class Meta: + ordering = ('-date_joined', ) + + def __str__(self): + return 'Joiner: {}'.format(self.joiner) + + @property + def joiner_display(self): + return str(self.joiner) + + def can_join(self): + # sharing + sharing_can_join, reason = self.sharing.can_join() + if not sharing_can_join: + return False, reason + # self + if self.verify_code != self.sharing.verify_code: + return False, _('Invalid verification code') + return True, '' + + def join_failed(self, reason): + self.is_success = False + self.reason = reason[:1024] + self.save() + + def finished(self): + if self.is_finished: + return + self.date_left = timezone.now() + self.is_finished = True + self.save() diff --git a/apps/terminal/models/terminal.py b/apps/terminal/models/terminal.py index 4a69c1112..d15010fef 100644 --- a/apps/terminal/models/terminal.py +++ b/apps/terminal/models/terminal.py @@ -151,7 +151,8 @@ class Terminal(StorageMixin, TerminalStatusMixin, models.Model): configs.update(self.get_replay_storage_setting()) configs.update(self.get_login_title_setting()) configs.update({ - 'SECURITY_MAX_IDLE_TIME': settings.SECURITY_MAX_IDLE_TIME + 'SECURITY_MAX_IDLE_TIME': settings.SECURITY_MAX_IDLE_TIME, + 'SECURITY_SESSION_SHARE': settings.SECURITY_SESSION_SHARE }) return configs diff --git a/apps/terminal/notifications.py b/apps/terminal/notifications.py index e9c83135e..46ac5b18d 100644 --- a/apps/terminal/notifications.py +++ b/apps/terminal/notifications.py @@ -81,7 +81,12 @@ class CommandAlertMessage(CommandAlertMixin, SystemMessage): return message def get_common_msg(self): - return self._get_message() + msg = self._get_message() + + return { + 'subject': msg[:80], + 'message': msg + } def get_email_msg(self): command = self.command @@ -140,9 +145,6 @@ class CommandExecutionAlert(CommandAlertMixin, SystemMessage): return message def get_common_msg(self): - return self._get_message() - - def get_email_msg(self): command = self.command subject = _("Insecure Web Command Execution Alert: [%(name)s]") % { diff --git a/apps/terminal/serializers/__init__.py b/apps/terminal/serializers/__init__.py index f1714dc21..a2a5bbf30 100644 --- a/apps/terminal/serializers/__init__.py +++ b/apps/terminal/serializers/__init__.py @@ -4,3 +4,4 @@ from .terminal import * from .session import * from .storage import * from .command import * +from .sharing import * diff --git a/apps/terminal/serializers/sharing.py b/apps/terminal/serializers/sharing.py new file mode 100644 index 000000000..5e8568cf5 --- /dev/null +++ b/apps/terminal/serializers/sharing.py @@ -0,0 +1,57 @@ +from rest_framework import serializers +from django.utils.translation import ugettext_lazy as _ +from orgs.mixins.serializers import OrgResourceModelSerializerMixin +from common.utils.random import random_string +from ..models import SessionSharing, SessionJoinRecord + +__all__ = ['SessionSharingSerializer', 'SessionJoinRecordSerializer'] + + +class SessionSharingSerializer(OrgResourceModelSerializerMixin): + class Meta: + model = SessionSharing + fields_mini = ['id'] + fields_small = fields_mini + [ + 'verify_code', 'is_active', 'expired_time', 'created_by', + 'date_created', 'date_updated' + ] + fields_fk = ['session', 'creator'] + fields = fields_small + fields_fk + read_only_fields = ['verify_code'] + + def create(self, validated_data): + validated_data['verify_code'] = random_string(4) + session = validated_data.get('session') + if session: + validated_data['creator_id'] = session.user_id + validated_data['created_by'] = str(session.user) + validated_data['org_id'] = session.org_id + return super().create(validated_data) + + +class SessionJoinRecordSerializer(OrgResourceModelSerializerMixin): + class Meta: + model = SessionJoinRecord + fields_mini = ['id'] + fields_small = fields_mini + [ + 'joiner_display', 'verify_code', 'date_joined', 'date_left', + 'remote_addr', 'login_from', 'is_success', 'reason', 'is_finished', + 'created_by', 'date_created', 'date_updated' + ] + fields_fk = ['session', 'sharing', 'joiner'] + fields = fields_small + fields_fk + extra_kwargs = { + 'session': {'required': False}, + 'joiner': {'required': True}, + 'sharing': {'required': True}, + 'remote_addr': {'required': True}, + 'verify_code': {'required': True}, + 'joiner_display': {'label': _('Joiner')}, + } + + def create(self, validate_data): + sharing = validate_data.get('sharing') + if sharing: + validate_data['session'] = sharing.session + validate_data['org_id'] = sharing.org_id + return super().create(validate_data) diff --git a/apps/terminal/serializers/terminal.py b/apps/terminal/serializers/terminal.py index 6f00c309a..073bceec0 100644 --- a/apps/terminal/serializers/terminal.py +++ b/apps/terminal/serializers/terminal.py @@ -99,15 +99,22 @@ class TerminalRegistrationSerializer(serializers.ModelSerializer): class Meta: model = Terminal fields = ['name', 'type', 'comment', 'service_account', 'remote_addr'] - extra_fields = { - 'remote_addr': {'readonly': True} + extra_kwargs = { + 'name': {'max_length': 1024}, + 'remote_addr': {'read_only': True} } def is_valid(self, raise_exception=False): valid = super().is_valid(raise_exception=raise_exception) if not valid: return valid - data = {'name': self.validated_data.get('name')} + name = self.validated_data.get('name') + if len(name) > 128: + self.validated_data['comment'] = name + name = '{}...{}'.format(name[:32], name[-32:]) + self.validated_data['name'] = name + + data = {'name': name} kwargs = {'data': data} if self.instance and self.instance.user: kwargs['instance'] = self.instance.user diff --git a/apps/terminal/startup.py b/apps/terminal/startup.py index 1e77c71a0..0626911ef 100644 --- a/apps/terminal/startup.py +++ b/apps/terminal/startup.py @@ -15,10 +15,15 @@ __all__ = ['CoreTerminal', 'CeleryTerminal'] class BaseTerminal(object): def __init__(self, suffix_name, _type): - self.server_hostname = os.environ.get('SERVER_HOSTNAME') or socket.gethostname() - self.name = f'[{suffix_name}] {self.server_hostname}' + server_hostname = os.environ.get('SERVER_HOSTNAME') or '' + hostname = socket.gethostname() + if server_hostname: + name = f'[{suffix_name}]-{server_hostname}' + else: + name = f'[{suffix_name}]-{hostname}' + self.name = name self.interval = 30 - self.remote_addr = socket.gethostbyname(socket.gethostname()) + self.remote_addr = socket.gethostbyname(hostname) self.type = _type def start_heartbeat_thread(self): diff --git a/apps/terminal/urls/api_urls.py b/apps/terminal/urls/api_urls.py index 57fb6eb73..edbf9db23 100644 --- a/apps/terminal/urls/api_urls.py +++ b/apps/terminal/urls/api_urls.py @@ -20,6 +20,8 @@ router.register(r'commands', api.CommandViewSet, 'command') router.register(r'status', api.StatusViewSet, 'status') router.register(r'replay-storages', api.ReplayStorageViewSet, 'replay-storage') router.register(r'command-storages', api.CommandStorageViewSet, 'command-storage') +router.register(r'session-sharings', api.SessionSharingViewSet, 'session-sharing') +router.register(r'session-join-records', api.SessionJoinRecordsViewSet, 'session-sharing-record') urlpatterns = [ path('terminal-registrations/', api.TerminalRegistrationApi.as_view(), name='terminal-registration'), diff --git a/apps/tickets/api/__init__.py b/apps/tickets/api/__init__.py index a6b5e39c6..a9d31b436 100644 --- a/apps/tickets/api/__init__.py +++ b/apps/tickets/api/__init__.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # from .ticket import * -from .assignee import * from .comment import * from .common import * diff --git a/apps/tickets/api/assignee.py b/apps/tickets/api/assignee.py deleted file mode 100644 index d95729085..000000000 --- a/apps/tickets/api/assignee.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- -# -from rest_framework import viewsets - -from common.permissions import IsValidUser -from common.exceptions import JMSException -from users.models import User -from orgs.models import Organization -from .. import serializers - - -class AssigneeViewSet(viewsets.ReadOnlyModelViewSet): - permission_classes = (IsValidUser,) - serializer_class = serializers.AssigneeSerializer - filterset_fields = ('id', 'name', 'username', 'email', 'source') - search_fields = filterset_fields - - def get_org(self): - org_id = self.request.query_params.get('org_id') - org = Organization.get_instance(org_id) - if not org: - error = ('The organization `{}` does not exist'.format(org_id)) - raise JMSException(error) - return org - - def get_queryset(self): - org = self.get_org() - queryset = User.get_super_and_org_admins(org=org) - return queryset diff --git a/apps/tickets/api/common.py b/apps/tickets/api/common.py index fe5a5d1e9..2838d23d0 100644 --- a/apps/tickets/api/common.py +++ b/apps/tickets/api/common.py @@ -15,16 +15,16 @@ class GenericTicketStatusRetrieveCloseAPI(RetrieveDestroyAPIView): permission_classes = (IsAppUser, ) def retrieve(self, request, *args, **kwargs): - if self.ticket.action_open: + if self.ticket.state_open: status = 'await' - elif self.ticket.action_approve: - status = 'approve' + elif self.ticket.state_approve: + status = 'approved' else: - status = 'reject' + status = 'rejected' data = { 'status': status, - 'action': self.ticket.action, - 'processor': self.ticket.processor_display + 'action': self.ticket.state, + 'processor': str(self.ticket.processor) } return Response(data=data, status=200) @@ -32,9 +32,9 @@ class GenericTicketStatusRetrieveCloseAPI(RetrieveDestroyAPIView): if self.ticket.status_open: self.ticket.close(processor=self.ticket.applicant) data = { - 'action': self.ticket.action, + 'action': self.ticket.state, 'status': self.ticket.status, - 'processor': self.ticket.processor_display + 'processor': str(self.ticket.processor) } return Response(data=data, status=200) diff --git a/apps/tickets/api/ticket.py b/apps/tickets/api/ticket.py index 086daef4d..cbbe72151 100644 --- a/apps/tickets/api/ticket.py +++ b/apps/tickets/api/ticket.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # -from django.utils.translation import ugettext_lazy as _ from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.exceptions import MethodNotAllowed @@ -8,14 +7,15 @@ from rest_framework.response import Response from common.const.http import POST, PUT from common.mixins.api import CommonApiMixin -from common.permissions import IsValidUser, IsOrgAdmin +from common.permissions import IsValidUser, IsOrgAdmin, IsSuperUser +from common.drf.api import JMSBulkModelViewSet from tickets import serializers -from tickets.models import Ticket -from tickets.permissions.ticket import IsAssignee, IsAssigneeOrApplicant, NotClosed +from tickets.models import Ticket, TicketFlow +from tickets.filters import TicketFilter +from tickets.permissions.ticket import IsAssignee, IsApplicant - -__all__ = ['TicketViewSet'] +__all__ = ['TicketViewSet', 'TicketFlowViewSet'] class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): @@ -25,12 +25,9 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): 'open': serializers.TicketApplySerializer, 'approve': serializers.TicketApproveSerializer, } - filterset_fields = [ - 'id', 'title', 'type', 'action', 'status', 'applicant', 'applicant_display', 'processor', - 'processor_display', 'assignees__id' - ] + filterset_class = TicketFilter search_fields = [ - 'title', 'action', 'type', 'status', 'applicant_display', 'processor_display' + 'title', 'action', 'type', 'status', 'applicant_display' ] def create(self, request, *args, **kwargs): @@ -48,6 +45,8 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): def perform_create(self, serializer): instance = serializer.save() + instance.create_related_node() + instance.process_map = instance.create_process_map() instance.open(applicant=self.request.user) @action(detail=False, methods=[POST], permission_classes=[IsValidUser, ]) @@ -57,24 +56,46 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): @action(detail=True, methods=[PUT], permission_classes=[IsAssignee, ]) def approve(self, request, *args, **kwargs): instance = self.get_object() - if instance.status_closed: - return Response(data={"error": _("Ticket already closed")}, status=400) - response = super().update(request, *args, **kwargs) - self.get_object().approve(processor=self.request.user) - return response + serializer = self.get_serializer(instance) + instance.approve(processor=request.user) + return Response(serializer.data) @action(detail=True, methods=[PUT], permission_classes=[IsAssignee, ]) def reject(self, request, *args, **kwargs): instance = self.get_object() - if instance.status_closed: - return Response(data={"error": _("Ticket already closed")}, status=400) serializer = self.get_serializer(instance) instance.reject(processor=request.user) return Response(serializer.data) - @action(detail=True, methods=[PUT], permission_classes=[IsAssigneeOrApplicant, NotClosed]) + @action(detail=True, methods=[PUT], permission_classes=[IsApplicant, ]) def close(self, request, *args, **kwargs): instance = self.get_object() serializer = self.get_serializer(instance) instance.close(processor=request.user) return Response(serializer.data) + + +class TicketFlowViewSet(JMSBulkModelViewSet): + permission_classes = (IsOrgAdmin, IsSuperUser) + serializer_class = serializers.TicketFlowSerializer + + filterset_fields = ['id', 'type'] + search_fields = ['id', 'type'] + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(self.action) + + def get_queryset(self): + queryset = TicketFlow.get_org_related_flows() + return queryset + + def perform_create_or_update(self, serializer): + instance = serializer.save() + instance.save() + instance.rules.model.change_assignees_display(instance.rules.all()) + + def perform_create(self, serializer): + self.perform_create_or_update(serializer) + + def perform_update(self, serializer): + self.perform_create_or_update(serializer) diff --git a/apps/tickets/const.py b/apps/tickets/const.py index 3397353d4..1d76ae487 100644 --- a/apps/tickets/const.py +++ b/apps/tickets/const.py @@ -1,10 +1,10 @@ -from django.db.models import TextChoices +from django.db.models import TextChoices, IntegerChoices from django.utils.translation import ugettext_lazy as _ TICKET_DETAIL_URL = '/ui/#/tickets/tickets/{id}' -class TicketTypeChoices(TextChoices): +class TicketType(TextChoices): general = 'general', _("General") login_confirm = 'login_confirm', _("Login confirm") apply_asset = 'apply_asset', _('Apply for asset') @@ -13,13 +13,38 @@ class TicketTypeChoices(TextChoices): command_confirm = 'command_confirm', _('Command confirm') -class TicketActionChoices(TextChoices): +class TicketState(TextChoices): open = 'open', _('Open') - approve = 'approve', _('Approve') - reject = 'reject', _('Reject') - close = 'close', _('Close') + approved = 'approved', _('Approved') + rejected = 'rejected', _('Rejected') + closed = 'closed', _('Closed') -class TicketStatusChoices(TextChoices): +class ProcessStatus(TextChoices): + notified = 'notified', _('Notified') + approved = 'approved', _('Approved') + rejected = 'rejected', _('Rejected') + + +class TicketStatus(TextChoices): open = 'open', _("Open") closed = 'closed', _("Closed") + + +class TicketAction(TextChoices): + open = 'open', _("Open") + close = 'close', _("Close") + approve = 'approve', _('Approve') + reject = 'reject', _('Reject') + + +class TicketApprovalLevel(IntegerChoices): + one = 1, _("One level") + two = 2, _("Two level") + + +class TicketApprovalStrategy(TextChoices): + super_admin = 'super_admin', _("Super admin") + org_admin = 'org_admin', _("Org admin") + super_org_admin = 'super_org_admin', _("Super admin and org admin") + custom_user = 'custom_user', _("Custom user") diff --git a/apps/tickets/errors.py b/apps/tickets/errors.py new file mode 100644 index 000000000..716eeca94 --- /dev/null +++ b/apps/tickets/errors.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# +from django.utils.translation import ugettext_lazy as _ + +from common.exceptions import JMSException + + +class AlreadyClosed(JMSException): + default_detail = _("Ticket already closed") diff --git a/apps/tickets/filters.py b/apps/tickets/filters.py new file mode 100644 index 000000000..676efbea2 --- /dev/null +++ b/apps/tickets/filters.py @@ -0,0 +1,18 @@ +from django_filters import rest_framework as filters +from common.drf.filters import BaseFilterSet + +from tickets.models import Ticket + + +class TicketFilter(BaseFilterSet): + assignees__id = filters.UUIDFilter(method='filter_assignees_id') + + class Meta: + model = Ticket + fields = ( + 'id', 'title', 'type', 'status', 'applicant', 'assignees__id', + 'applicant_display', + ) + + def filter_assignees_id(self, queryset, name, value): + return queryset.filter(ticket_steps__ticket_assignees__assignee__id=value) diff --git a/apps/tickets/handler/apply_application.py b/apps/tickets/handler/apply_application.py index f4643b5c6..8d03a995d 100644 --- a/apps/tickets/handler/apply_application.py +++ b/apps/tickets/handler/apply_application.py @@ -1,17 +1,19 @@ from django.utils.translation import ugettext as _ from orgs.utils import tmp_to_org, tmp_to_root_org -from applications.models import Application from applications.const import AppCategory, AppType -from assets.models import SystemUser +from applications.models import Application from perms.models import ApplicationPermission +from assets.models import SystemUser + from .base import BaseHandler class Handler(BaseHandler): def _on_approve(self): - super()._on_approve() - self._create_application_permission() + is_finished = super()._on_approve() + if is_finished: + self._create_application_permission() # display def _construct_meta_display_of_open(self): @@ -22,27 +24,21 @@ class Handler(BaseHandler): apply_type_display = AppType.get_label(apply_type) meta_display_values = [apply_category_display, apply_type_display] meta_display = dict(zip(meta_display_fields, meta_display_values)) - return meta_display + apply_system_users = self.ticket.meta.get('apply_system_users') + apply_applications = self.ticket.meta.get('apply_applications') + meta_display.update({ + 'apply_system_users_display': [str(i) for i in SystemUser.objects.filter(id__in=apply_system_users)], + 'apply_applications_display': [str(i) for i in Application.objects.filter(id__in=apply_applications)] + }) - def _construct_meta_display_of_approve(self): - meta_display_fields = ['approve_applications_display', 'approve_system_users_display'] - approve_application_ids = self.ticket.meta.get('approve_applications', []) - approve_system_user_ids = self.ticket.meta.get('approve_system_users', []) - with tmp_to_org(self.ticket.org_id): - approve_applications = Application.objects.filter(id__in=approve_application_ids) - system_users = SystemUser.objects.filter(id__in=approve_system_user_ids) - approve_applications_display = [str(application) for application in approve_applications] - approve_system_users_display = [str(system_user) for system_user in system_users] - meta_display_values = [approve_applications_display, approve_system_users_display] - meta_display = dict(zip(meta_display_fields, meta_display_values)) return meta_display # body def _construct_meta_body_of_open(self): apply_category_display = self.ticket.meta.get('apply_category_display') apply_type_display = self.ticket.meta.get('apply_type_display') - apply_application_group = self.ticket.meta.get('apply_application_group', []) - apply_system_user_group = self.ticket.meta.get('apply_system_user_group', []) + apply_applications = self.ticket.meta.get('apply_applications', []) + apply_system_users = self.ticket.meta.get('apply_system_users', []) apply_date_start = self.ticket.meta.get('apply_date_start') apply_date_expired = self.ticket.meta.get('apply_date_expired') applied_body = '''{}: {}, @@ -54,31 +50,13 @@ class Handler(BaseHandler): '''.format( _('Applied category'), apply_category_display, _('Applied type'), apply_type_display, - _('Applied application group'), apply_application_group, - _('Applied system user group'), apply_system_user_group, + _('Applied application group'), apply_applications, + _('Applied system user group'), apply_system_users, _('Applied date start'), apply_date_start, _('Applied date expired'), apply_date_expired, ) return applied_body - def _construct_meta_body_of_approve(self): - # 审批信息 - approve_applications_display = self.ticket.meta.get('approve_applications_display', []) - approve_system_users_display = self.ticket.meta.get('approve_system_users_display', []) - approve_date_start = self.ticket.meta.get('approve_date_start') - approve_date_expired = self.ticket.meta.get('approve_date_expired') - approved_body = '''{}: {}, - {}: {}, - {}: {}, - {}: {}, - '''.format( - _('Approved applications'), approve_applications_display, - _('Approved system users'), approve_system_users_display, - _('Approved date start'), approve_date_start, - _('Approved date expired'), approve_date_expired - ) - return approved_body - # permission def _create_application_permission(self): with tmp_to_root_org(): @@ -88,11 +66,11 @@ class Handler(BaseHandler): apply_category = self.ticket.meta.get('apply_category') apply_type = self.ticket.meta.get('apply_type') - approve_permission_name = self.ticket.meta.get('approve_permission_name', '') - approved_application_ids = self.ticket.meta.get('approve_applications', []) - approve_system_user_ids = self.ticket.meta.get('approve_system_users', []) - approve_date_start = self.ticket.meta.get('approve_date_start') - approve_date_expired = self.ticket.meta.get('approve_date_expired') + apply_permission_name = self.ticket.meta.get('apply_permission_name', '') + apply_applications = self.ticket.meta.get('apply_applications', []) + apply_system_users = self.ticket.meta.get('apply_system_users', []) + apply_date_start = self.ticket.meta.get('apply_date_start') + apply_date_expired = self.ticket.meta.get('apply_date_expired') permission_created_by = '{}:{}'.format( str(self.ticket.__class__.__name__), str(self.ticket.id) ) @@ -105,23 +83,24 @@ class Handler(BaseHandler): ).format( self.ticket.title, self.ticket.applicant_display, - self.ticket.processor_display, + str(self.ticket.processor), str(self.ticket.id) ) permissions_data = { 'id': self.ticket.id, - 'name': approve_permission_name, + 'name': apply_permission_name, + 'from_ticket': True, 'category': apply_category, 'type': apply_type, 'comment': str(permission_comment), 'created_by': permission_created_by, - 'date_start': approve_date_start, - 'date_expired': approve_date_expired, + 'date_start': apply_date_start, + 'date_expired': apply_date_expired, } with tmp_to_org(self.ticket.org_id): application_permission = ApplicationPermission.objects.create(**permissions_data) application_permission.users.add(self.ticket.applicant) - application_permission.applications.set(approved_application_ids) - application_permission.system_users.set(approve_system_user_ids) + application_permission.applications.set(apply_applications) + application_permission.system_users.set(apply_system_users) return application_permission diff --git a/apps/tickets/handler/apply_asset.py b/apps/tickets/handler/apply_asset.py index 9af310294..ae8ded3b1 100644 --- a/apps/tickets/handler/apply_asset.py +++ b/apps/tickets/handler/apply_asset.py @@ -1,16 +1,19 @@ +from assets.models import Asset +from assets.models import SystemUser + from .base import BaseHandler from django.utils.translation import ugettext as _ from perms.models import AssetPermission, Action -from assets.models import Asset, SystemUser from orgs.utils import tmp_to_org, tmp_to_root_org class Handler(BaseHandler): def _on_approve(self): - super()._on_approve() - self._create_asset_permission() + is_finished = super()._on_approve() + if is_finished: + self._create_asset_permission() # display def _construct_meta_display_of_open(self): @@ -19,32 +22,18 @@ class Handler(BaseHandler): apply_actions_display = Action.value_to_choices_display(apply_actions) meta_display_values = [apply_actions_display] meta_display = dict(zip(meta_display_fields, meta_display_values)) - return meta_display - - def _construct_meta_display_of_approve(self): - meta_display_fields = [ - 'approve_actions_display', 'approve_assets_display', 'approve_system_users_display' - ] - approve_actions = self.ticket.meta.get('approve_actions', Action.NONE) - approve_actions_display = Action.value_to_choices_display(approve_actions) - approve_asset_ids = self.ticket.meta.get('approve_assets', []) - approve_system_user_ids = self.ticket.meta.get('approve_system_users', []) - with tmp_to_org(self.ticket.org_id): - assets = Asset.objects.filter(id__in=approve_asset_ids) - system_users = SystemUser.objects.filter(id__in=approve_system_user_ids) - approve_assets_display = [str(asset) for asset in assets] - approve_system_users_display = [str(system_user) for system_user in system_users] - meta_display_values = [ - approve_actions_display, approve_assets_display, approve_system_users_display - ] - meta_display = dict(zip(meta_display_fields, meta_display_values)) + apply_assets = self.ticket.meta.get('apply_assets') + apply_system_users = self.ticket.meta.get('apply_system_users') + meta_display.update({ + 'apply_assets_display': [str(i) for i in Asset.objects.filter(id__in=apply_assets)], + 'apply_system_users_display': [str(i)for i in SystemUser.objects.filter(id__in=apply_system_users)] + }) return meta_display # body def _construct_meta_body_of_open(self): - apply_ip_group = self.ticket.meta.get('apply_ip_group', []) - apply_hostname_group = self.ticket.meta.get('apply_hostname_group', []) - apply_system_user_group = self.ticket.meta.get('apply_system_user_group', []) + apply_assets = self.ticket.meta.get('apply_assets', []) + apply_system_users = self.ticket.meta.get('apply_system_users', []) apply_actions_display = self.ticket.meta.get('apply_actions_display', []) apply_date_start = self.ticket.meta.get('apply_date_start') apply_date_expired = self.ticket.meta.get('apply_date_expired') @@ -54,35 +43,14 @@ class Handler(BaseHandler): {}: {}, {}: {} '''.format( - _('Applied IP group'), apply_ip_group, - _("Applied hostname group"), apply_hostname_group, - _("Applied system user group"), apply_system_user_group, + _("Applied hostname group"), apply_assets, + _("Applied system user group"), apply_system_users, _("Applied actions"), apply_actions_display, _('Applied date start'), apply_date_start, _('Applied date expired'), apply_date_expired, ) return applied_body - def _construct_meta_body_of_approve(self): - approve_assets_display = self.ticket.meta.get('approve_assets_display', []) - approve_system_users_display = self.ticket.meta.get('approve_system_users_display', []) - approve_actions_display = self.ticket.meta.get('approve_actions_display', []) - approve_date_start = self.ticket.meta.get('approve_date_start') - approve_date_expired = self.ticket.meta.get('approve_date_expired') - approved_body = '''{}: {}, - {}: {}, - {}: {}, - {}: {}, - {}: {} - '''.format( - _('Approved assets'), approve_assets_display, - _('Approved system users'), approve_system_users_display, - _('Approved actions'), ', '.join(approve_actions_display), - _('Approved date start'), approve_date_start, - _('Approved date expired'), approve_date_expired, - ) - return approved_body - # permission def _create_asset_permission(self): with tmp_to_root_org(): @@ -90,12 +58,12 @@ class Handler(BaseHandler): if asset_permission: return asset_permission - approve_permission_name = self.ticket.meta.get('approve_permission_name', ) - approve_asset_ids = self.ticket.meta.get('approve_assets', []) - approve_system_user_ids = self.ticket.meta.get('approve_system_users', []) - approve_actions = self.ticket.meta.get('approve_actions', Action.NONE) - approve_date_start = self.ticket.meta.get('approve_date_start') - approve_date_expired = self.ticket.meta.get('approve_date_expired') + apply_permission_name = self.ticket.meta.get('apply_permission_name', ) + apply_assets = self.ticket.meta.get('apply_assets', []) + apply_system_users = self.ticket.meta.get('apply_system_users', []) + apply_actions = self.ticket.meta.get('apply_actions', Action.NONE) + apply_date_start = self.ticket.meta.get('apply_date_start') + apply_date_expired = self.ticket.meta.get('apply_date_expired') permission_created_by = '{}:{}'.format( str(self.ticket.__class__.__name__), str(self.ticket.id) ) @@ -108,23 +76,24 @@ class Handler(BaseHandler): ).format( self.ticket.title, self.ticket.applicant_display, - self.ticket.processor_display, + str(self.ticket.processor), str(self.ticket.id) ) permission_data = { 'id': self.ticket.id, - 'name': approve_permission_name, + 'name': apply_permission_name, + 'from_ticket': True, 'comment': str(permission_comment), 'created_by': permission_created_by, - 'actions': approve_actions, - 'date_start': approve_date_start, - 'date_expired': approve_date_expired, + 'actions': apply_actions, + 'date_start': apply_date_start, + 'date_expired': apply_date_expired, } with tmp_to_org(self.ticket.org_id): asset_permission = AssetPermission.objects.create(**permission_data) asset_permission.users.add(self.ticket.applicant) - asset_permission.assets.set(approve_asset_ids) - asset_permission.system_users.set(approve_system_user_ids) + asset_permission.assets.set(apply_assets) + asset_permission.system_users.set(apply_system_users) return asset_permission diff --git a/apps/tickets/handler/base.py b/apps/tickets/handler/base.py index 24a35268a..e308d7255 100644 --- a/apps/tickets/handler/base.py +++ b/apps/tickets/handler/base.py @@ -3,7 +3,7 @@ from common.utils import get_logger from tickets.utils import ( send_ticket_processed_mail_to_applicant, send_ticket_applied_mail_to_assignees ) - +from tickets.const import TicketAction logger = get_logger(__name__) @@ -16,48 +16,72 @@ class BaseHandler(object): # on action def _on_open(self): self.ticket.applicant_display = str(self.ticket.applicant) - self.ticket.assignees_display = [str(assignee) for assignee in self.ticket.assignees.all()] meta_display = getattr(self, '_construct_meta_display_of_open', lambda: {})() self.ticket.meta.update(meta_display) self.ticket.save() self._send_applied_mail_to_assignees() def _on_approve(self): - meta_display = getattr(self, '_construct_meta_display_of_approve', lambda: {})() - self.ticket.meta.update(meta_display) - self.__on_process() + if self.ticket.approval_step != len(self.ticket.process_map): + self.ticket.approval_step += 1 + self.ticket.create_related_node() + is_finished = False + else: + self.ticket.set_state_approve() + self.ticket.set_status_closed() + is_finished = True + self._send_applied_mail_to_assignees() + + self.__on_process(self.ticket.processor) + return is_finished def _on_reject(self): - self.__on_process() + self.ticket.set_state_reject() + self.ticket.set_status_closed() + self.__on_process(self.ticket.processor) def _on_close(self): - self.__on_process() - - def __on_process(self): - self.ticket.processor_display = str(self.ticket.processor) + self.ticket.set_state_closed() self.ticket.set_status_closed() - self._send_processed_mail_to_applicant() + self.__on_process(self.ticket.processor) + + def __on_process(self, processor): + self._send_processed_mail_to_applicant(processor) self.ticket.save() def dispatch(self, action): - self._create_comment_on_action() + processor = self.ticket.processor + current_node = self.ticket.current_node.first() + self.ticket.process_map[self.ticket.approval_step - 1].update({ + 'approval_date': str(current_node.date_updated), + 'state': current_node.state, + 'processor': processor.id if processor else '', + 'processor_display': str(processor) if processor else '', + }) + self.ticket.save() + self._create_comment_on_action(action) method = getattr(self, f'_on_{action}', lambda: None) return method() # email def _send_applied_mail_to_assignees(self): - logger.debug('Send applied email to assignees: {}'.format(self.ticket.assignees_display)) + assignees = self.ticket.current_node.first().ticket_assignees.all() + assignees_display = ', '.join([str(i.assignee) for i in assignees]) + logger.debug('Send applied email to assignees: {}'.format(assignees_display)) send_ticket_applied_mail_to_assignees(self.ticket) - def _send_processed_mail_to_applicant(self): + def _send_processed_mail_to_applicant(self, processor): logger.debug('Send processed mail to applicant: {}'.format(self.ticket.applicant_display)) - send_ticket_processed_mail_to_applicant(self.ticket) + send_ticket_processed_mail_to_applicant(self.ticket, processor) # comments - def _create_comment_on_action(self): - user = self.ticket.applicant if self.ticket.action_open else self.ticket.processor + def _create_comment_on_action(self, action): + user = self.ticket.processor + # 打开或关闭工单,备注显示是自己,其他是受理人 + if self.ticket.state_open or self.ticket.state_close: + user = self.ticket.applicant user_display = str(user) - action_display = self.ticket.get_action_display() + action_display = getattr(TicketAction, action).label data = { 'body': _('{} {} the ticket').format(user_display, action_display), 'user': user, @@ -85,18 +109,12 @@ class BaseHandler(object): {}: {}, {}: {}, {}: {}, - {}: {}, - {}: {} '''.format( _('Ticket title'), self.ticket.title, _('Ticket type'), self.ticket.get_type_display(), _('Ticket status'), self.ticket.get_status_display(), - _('Ticket action'), self.ticket.get_action_display(), _('Ticket applicant'), self.ticket.applicant_display, - _('Ticket assignees'), ', '.join(self.ticket.assignees_display), ) - if self.ticket.status_closed: - basic_body += '''{}: {}'''.format(_('Ticket processor'), self.ticket.processor_display) body = self.body_html_format.format(_("Ticket basic info"), basic_body) return body @@ -104,9 +122,6 @@ class BaseHandler(object): body = '' open_body = self._base_construct_meta_body_of_open() body += open_body - if self.ticket.action_approve: - approve_body = self._base_construct_meta_body_of_approve() - body += approve_body return body def _base_construct_meta_body_of_open(self): @@ -115,10 +130,3 @@ class BaseHandler(object): )() body = self.body_html_format.format(_('Ticket applied info'), meta_body_of_open) return body - - def _base_construct_meta_body_of_approve(self): - meta_body_of_approve = getattr( - self, '_construct_meta_body_of_approve', lambda: _('No content') - )() - body = self.body_html_format.format(_('Ticket approved info'), meta_body_of_approve) - return body diff --git a/apps/tickets/migrations/0010_auto_20210812_1618.py b/apps/tickets/migrations/0010_auto_20210812_1618.py new file mode 100644 index 000000000..36e4db4e2 --- /dev/null +++ b/apps/tickets/migrations/0010_auto_20210812_1618.py @@ -0,0 +1,233 @@ +# Generated by Django 3.1.6 on 2021-08-12 08:18 + +import common.db.encoder +from django.conf import settings +from django.db import migrations, models, transaction +import django.db.models.deletion +import uuid + +from tickets.const import TicketType, TicketApprovalStrategy + +ticket_assignee_m2m = list() + + +def get_ticket_assignee_m2m_info(apps, schema_editor): + ticket_model = apps.get_model("tickets", "Ticket") + for i in ticket_model.objects.only('id', 'assignees', 'action', 'created_by'): + ticket_assignee_m2m.append((i.id, list(i.assignees.values_list('id', flat=True)), i.action, i.created_by)) + + +def update_ticket_process_meta_state_status(apps, schema_editor): + ticket_model = apps.get_model("tickets", "Ticket") + updates = list() + with transaction.atomic(): + for instance in ticket_model.objects.all(): + if instance.action == 'open': + state = 'notified' + elif instance.action == 'approve': + state = 'approved' + elif instance.action == 'reject': + state = 'rejected' + else: + state = 'closed' + instance.process_map = [{ + 'state': state, + 'approval_level': 1, + 'approval_date': str(instance.date_updated), + 'processor': instance.processor.id if instance.processor else '', + 'processor_display': instance.processor_display if instance.processor_display else '', + 'assignees': list(instance.assignees.values_list('id', flat=True)) if instance.assignees else [], + 'assignees_display': instance.assignees_display if instance.assignees_display else [] + }, ] + instance.state = state + instance.meta['apply_assets'] = instance.meta.pop('approve_assets', []) + instance.meta['apply_assets_display'] = instance.meta.pop('approve_assets_display', []) + instance.meta['apply_actions'] = instance.meta.pop('approve_actions', 0) + instance.meta['apply_actions_display'] = instance.meta.pop('approve_actions_display', []) + instance.meta['apply_applications'] = instance.meta.pop('approve_applications', []) + instance.meta['apply_applications_display'] = instance.meta.pop('approve_applications_display', []) + instance.meta['apply_system_users'] = instance.meta.pop('approve_system_users', []) + instance.meta['apply_system_users_display'] = instance.meta.pop('approve_system_users_display', []) + updates.append(instance) + ticket_model.objects.bulk_update(updates, ['process_map', 'state', 'meta', 'status']) + + +def create_step_and_assignee(apps, schema_editor): + ticket_step_model = apps.get_model("tickets", "TicketStep") + ticket_assignee_model = apps.get_model("tickets", "TicketAssignee") + creates = list() + with transaction.atomic(): + for ticket_id, assignees, action, created_by in ticket_assignee_m2m: + if action == 'open': + state = 'notified' + elif action == 'approve': + state = 'approved' + else: + state = 'rejected' + step_instance = ticket_step_model.objects.create(ticket_id=ticket_id, state=state, created_by=created_by) + for assignee_id in assignees: + creates.append( + ticket_assignee_model( + step=step_instance, assignee_id=assignee_id, state=state, created_by=created_by + ) + ) + ticket_assignee_model.objects.bulk_create(creates) + + +def create_ticket_flow_and_approval_rule(apps, schema_editor): + user_model = apps.get_model("users", "User") + org_id = '00000000-0000-0000-0000-000000000000' + ticket_flow_model = apps.get_model("tickets", "TicketFlow") + approval_rule_model = apps.get_model("tickets", "ApprovalRule") + super_user = user_model.objects.filter(role='Admin') + assignees_display = ['{0.name}({0.username})'.format(i) for i in super_user] + with transaction.atomic(): + for ticket_type in [TicketType.apply_asset, TicketType.apply_application]: + ticket_flow_instance = ticket_flow_model.objects.create(created_by='System', type=ticket_type, org_id=org_id) + approval_rule_instance = approval_rule_model.objects.create(strategy=TicketApprovalStrategy.super_admin, assignees_display=assignees_display) + approval_rule_instance.assignees.set(list(super_user)) + ticket_flow_instance.rules.set([approval_rule_instance, ]) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tickets', '0009_auto_20210426_1720'), + ] + + operations = [ + migrations.CreateModel( + name='ApprovalRule', + 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')), + ('level', models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, + verbose_name='Approve level')), + ('strategy', models.CharField( + choices=[('super_admin', 'Super admin'), ('org_admin', 'Org admin'), ('super_org_admin', 'Super admin and org admin'), + ('custom_user', 'Custom user')], + default='super_admin', max_length=64, verbose_name='Approve strategy')), + ('assignees_display', models.JSONField(default=list, encoder=common.db.encoder.ModelJSONFieldEncoder, + verbose_name='Assignees display')), + ('assignees', + models.ManyToManyField(related_name='assigned_ticket_flow_approval_rule', to=settings.AUTH_USER_MODEL, + verbose_name='Assignees')), + ], + options={ + 'verbose_name': 'Ticket flow approval rule', + }, + ), + migrations.RunPython(get_ticket_assignee_m2m_info), + migrations.AddField( + model_name='ticket', + name='process_map', + field=models.JSONField(default=list, encoder=common.db.encoder.ModelJSONFieldEncoder, + verbose_name='Process'), + ), + migrations.AddField( + model_name='ticket', + name='state', + field=models.CharField( + choices=[('open', 'Open'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('closed', 'Closed')], + default='open', max_length=16, verbose_name='State'), + ), + migrations.RunPython(update_ticket_process_meta_state_status), + migrations.RemoveField( + model_name='ticket', + name='action', + ), + migrations.RemoveField( + model_name='ticket', + name='assignees', + ), + migrations.RemoveField( + model_name='ticket', + name='assignees_display', + ), + migrations.RemoveField( + model_name='ticket', + name='processor', + ), + migrations.RemoveField( + model_name='ticket', + name='processor_display', + ), + migrations.AddField( + model_name='ticket', + name='approval_step', + field=models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, + verbose_name='Approval step'), + ), + migrations.CreateModel( + name='TicketStep', + 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')), + ('level', models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, + verbose_name='Approve level')), + ('state', models.CharField( + choices=[('notified', 'Notified'), ('approved', 'Approved'), ('rejected', 'Rejected')], + default='notified', max_length=64)), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_steps', + to='tickets.ticket', verbose_name='Ticket')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TicketFlow', + fields=[ + ('org_id', + models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('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')), + ('type', models.CharField(choices=[('general', 'General'), ('login_confirm', 'Login confirm'), + ('apply_asset', 'Apply for asset'), + ('apply_application', 'Apply for application'), + ('login_asset_confirm', 'Login asset confirm'), + ('command_confirm', 'Command confirm')], default='general', + max_length=64, verbose_name='Type')), + ('approval_level', models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, + verbose_name='Approve level')), + ('rules', models.ManyToManyField(related_name='ticket_flows', to='tickets.ApprovalRule')), + ], + options={ + 'verbose_name': 'Ticket flow', + }, + ), + migrations.CreateModel( + name='TicketAssignee', + 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')), + ('state', models.CharField( + choices=[('notified', 'Notified'), ('approved', 'Approved'), ('rejected', 'Rejected')], + default='notified', max_length=64)), + ('assignee', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_assignees', + to=settings.AUTH_USER_MODEL, verbose_name='Assignee')), + ('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_assignees', + to='tickets.ticketstep')), + ], + options={ + 'verbose_name': 'Ticket assignee', + }, + ), + migrations.RunPython(create_step_and_assignee), + migrations.RunPython(create_ticket_flow_and_approval_rule), + migrations.AddField( + model_name='ticket', + name='flow', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', + to='tickets.ticketflow', verbose_name='TicketFlow'), + ), + ] diff --git a/apps/tickets/models/__init__.py b/apps/tickets/models/__init__.py index 4f7ca772b..fd2bd9057 100644 --- a/apps/tickets/models/__init__.py +++ b/apps/tickets/models/__init__.py @@ -2,3 +2,5 @@ # from .ticket import * from .comment import * +from .flow import * + diff --git a/apps/tickets/models/flow.py b/apps/tickets/models/flow.py new file mode 100644 index 000000000..9f008f166 --- /dev/null +++ b/apps/tickets/models/flow.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from common.mixins.models import CommonModelMixin +from common.db.encoder import ModelJSONFieldEncoder +from orgs.mixins.models import OrgModelMixin +from orgs.utils import tmp_to_root_org +from ..const import TicketType, TicketApprovalLevel, TicketApprovalStrategy +from ..signals import post_or_update_change_ticket_flow_approval + +__all__ = ['TicketFlow', 'ApprovalRule'] + + +class ApprovalRule(CommonModelMixin): + level = models.SmallIntegerField( + default=TicketApprovalLevel.one, choices=TicketApprovalLevel.choices, + verbose_name=_('Approve level') + ) + strategy = models.CharField( + max_length=64, default=TicketApprovalStrategy.super_admin, + choices=TicketApprovalStrategy.choices, + verbose_name=_('Approve strategy') + ) + # 受理人列表 + assignees = models.ManyToManyField( + 'users.User', related_name='assigned_ticket_flow_approval_rule', + verbose_name=_("Assignees") + ) + assignees_display = models.JSONField( + encoder=ModelJSONFieldEncoder, default=list, + verbose_name=_('Assignees display') + ) + + class Meta: + verbose_name = _('Ticket flow approval rule') + + def __str__(self): + return '{}({})'.format(self.id, self.level) + + @classmethod + def change_assignees_display(cls, qs): + post_or_update_change_ticket_flow_approval.send(sender=cls, qs=qs) + + +class TicketFlow(CommonModelMixin, OrgModelMixin): + type = models.CharField( + max_length=64, choices=TicketType.choices, + default=TicketType.general, verbose_name=_("Type") + ) + approval_level = models.SmallIntegerField( + default=TicketApprovalLevel.one, + choices=TicketApprovalLevel.choices, + verbose_name=_('Approve level') + ) + rules = models.ManyToManyField(ApprovalRule, related_name='ticket_flows') + + class Meta: + verbose_name = _('Ticket flow') + + def __str__(self): + return '{}'.format(self.type) + + @classmethod + def get_org_related_flows(cls): + flows = cls.objects.all() + cur_flow_types = flows.values_list('type', flat=True) + with tmp_to_root_org(): + diff_global_flows = cls.objects.exclude(type__in=cur_flow_types) + return flows | diff_global_flows diff --git a/apps/tickets/models/ticket.py b/apps/tickets/models/ticket.py index 41d23c8b0..9abd29c96 100644 --- a/apps/tickets/models/ticket.py +++ b/apps/tickets/models/ticket.py @@ -1,76 +1,78 @@ # -*- coding: utf-8 -*- # -import json -import uuid -from datetime import datetime from django.db import models from django.db.models import Q from django.utils.translation import ugettext_lazy as _ -from django.conf import settings from common.mixins.models import CommonModelMixin +from common.db.encoder import ModelJSONFieldEncoder from orgs.mixins.models import OrgModelMixin from orgs.utils import tmp_to_root_org, tmp_to_org -from tickets.const import TicketTypeChoices, TicketActionChoices, TicketStatusChoices +from tickets.const import TicketType, TicketStatus, TicketState, TicketApprovalLevel, ProcessStatus, TicketAction from tickets.signals import post_change_ticket_action from tickets.handler import get_ticket_handler +from tickets.errors import AlreadyClosed -__all__ = ['Ticket', 'ModelJSONFieldEncoder'] +__all__ = ['Ticket'] -class ModelJSONFieldEncoder(json.JSONEncoder): - """ 解决一些类型的字段不能序列化的问题 """ - def default(self, obj): - if isinstance(obj, datetime): - return obj.strftime(settings.DATETIME_DISPLAY_FORMAT) - if isinstance(obj, uuid.UUID): - return str(obj) - if isinstance(obj, type(_("ugettext_lazy"))): - return str(obj) - else: - return super().default(obj) +class TicketStep(CommonModelMixin): + ticket = models.ForeignKey( + 'Ticket', related_name='ticket_steps', on_delete=models.CASCADE, verbose_name='Ticket' + ) + level = models.SmallIntegerField( + default=TicketApprovalLevel.one, choices=TicketApprovalLevel.choices, + verbose_name=_('Approve level') + ) + state = models.CharField(choices=ProcessStatus.choices, max_length=64, default=ProcessStatus.notified) + + +class TicketAssignee(CommonModelMixin): + assignee = models.ForeignKey( + 'users.User', related_name='ticket_assignees', on_delete=models.CASCADE, verbose_name='Assignee' + ) + state = models.CharField(choices=ProcessStatus.choices, max_length=64, default=ProcessStatus.notified) + step = models.ForeignKey('tickets.TicketStep', related_name='ticket_assignees', on_delete=models.CASCADE) + + class Meta: + verbose_name = _('Ticket assignee') + + def __str__(self): + return '{0.assignee.name}({0.assignee.username})_{0.step}'.format(self) class Ticket(CommonModelMixin, OrgModelMixin): title = models.CharField(max_length=256, verbose_name=_("Title")) type = models.CharField( - max_length=64, choices=TicketTypeChoices.choices, - default=TicketTypeChoices.general.value, verbose_name=_("Type") + max_length=64, choices=TicketType.choices, + default=TicketType.general, verbose_name=_("Type") ) meta = models.JSONField(encoder=ModelJSONFieldEncoder, default=dict, verbose_name=_("Meta")) - action = models.CharField( - choices=TicketActionChoices.choices, max_length=16, - default=TicketActionChoices.open.value, verbose_name=_("Action") + state = models.CharField( + max_length=16, choices=TicketState.choices, + default=TicketState.open, verbose_name=_("State") ) status = models.CharField( - max_length=16, choices=TicketStatusChoices.choices, - default=TicketStatusChoices.open.value, verbose_name=_("Status") + max_length=16, choices=TicketStatus.choices, + default=TicketStatus.open, verbose_name=_("Status") + ) + approval_step = models.SmallIntegerField( + default=TicketApprovalLevel.one, choices=TicketApprovalLevel.choices, + verbose_name=_('Approval step') ) # 申请人 applicant = models.ForeignKey( 'users.User', related_name='applied_tickets', on_delete=models.SET_NULL, null=True, verbose_name=_("Applicant") ) - applicant_display = models.CharField( - max_length=256, default='', verbose_name=_("Applicant display") - ) - # 处理人 - processor = models.ForeignKey( - 'users.User', related_name='processed_tickets', on_delete=models.SET_NULL, null=True, - verbose_name=_("Processor") - ) - processor_display = models.CharField( - max_length=256, blank=True, null=True, default='', verbose_name=_("Processor display") - ) - # 受理人列表 - assignees = models.ManyToManyField( - 'users.User', related_name='assigned_tickets', verbose_name=_("Assignees") - ) - assignees_display = models.JSONField( - encoder=ModelJSONFieldEncoder, default=list, verbose_name=_('Assignees display') - ) + applicant_display = models.CharField(max_length=256, default='', verbose_name=_("Applicant display")) + process_map = models.JSONField(encoder=ModelJSONFieldEncoder, default=list, verbose_name=_("Process")) # 评论 comment = models.TextField(default='', blank=True, verbose_name=_('Comment')) + flow = models.ForeignKey( + 'TicketFlow', related_name='tickets', on_delete=models.SET_NULL, null=True, + verbose_name=_("TicketFlow") + ) class Meta: ordering = ('-date_created',) @@ -81,77 +83,142 @@ class Ticket(CommonModelMixin, OrgModelMixin): # type @property def type_apply_asset(self): - return self.type == TicketTypeChoices.apply_asset.value + return self.type == TicketType.apply_asset.value @property def type_apply_application(self): - return self.type == TicketTypeChoices.apply_application.value + return self.type == TicketType.apply_application.value @property def type_login_confirm(self): - return self.type == TicketTypeChoices.login_confirm.value + return self.type == TicketType.login_confirm.value # status - @property - def status_closed(self): - return self.status == TicketStatusChoices.closed.value - @property def status_open(self): - return self.status == TicketStatusChoices.open.value + return self.status == TicketStatus.open.value + + @property + def status_closed(self): + return self.status == TicketStatus.closed.value + + @property + def state_open(self): + return self.state == TicketState.open.value + + @property + def state_approve(self): + return self.state == TicketState.approved.value + + @property + def state_reject(self): + return self.state == TicketState.rejected.value + + @property + def state_close(self): + return self.state == TicketState.closed.value + + @property + def current_node(self): + return self.ticket_steps.filter(level=self.approval_step) + + @property + def processor(self): + processor = self.current_node.first().ticket_assignees.exclude(state=ProcessStatus.notified).first() + return processor.assignee if processor else None + + def set_state_approve(self): + self.state = TicketState.approved + + def set_state_reject(self): + self.state = TicketState.rejected + + def set_state_closed(self): + self.state = TicketState.closed def set_status_closed(self): - self.status = TicketStatusChoices.closed.value + self.status = TicketStatus.closed - # action - @property - def action_open(self): - return self.action == TicketActionChoices.open.value + def create_related_node(self): + approval_rule = self.get_current_ticket_flow_approve() + ticket_step = TicketStep.objects.create(ticket=self, level=self.approval_step) + ticket_assignees = [] + assignees = approval_rule.assignees.all() + for assignee in assignees: + ticket_assignees.append(TicketAssignee(step=ticket_step, assignee=assignee)) + TicketAssignee.objects.bulk_create(ticket_assignees) - @property - def action_approve(self): - return self.action == TicketActionChoices.approve.value + def create_process_map(self): + approval_rules = self.flow.rules.order_by('level') + nodes = list() + for node in approval_rules: + nodes.append( + { + 'approval_level': node.level, + 'state': ProcessStatus.notified, + 'assignees': [i for i in node.assignees.values_list('id', flat=True)], + 'assignees_display': node.assignees_display + } + ) + return nodes - @property - def action_reject(self): - return self.action == TicketActionChoices.reject.value - - @property - def action_close(self): - return self.action == TicketActionChoices.close.value + # TODO 兼容不存在流的工单 + def create_process_map_and_node(self, assignees): + self.process_map = [{ + 'approval_level': 1, + 'state': 'notified', + 'assignees': [assignee.id for assignee in assignees], + 'assignees_display': [str(assignee) for assignee in assignees] + }, ] + self.save() + ticket_step = TicketStep.objects.create(ticket=self, level=1) + ticket_assignees = [] + for assignee in assignees: + ticket_assignees.append(TicketAssignee(step=ticket_step, assignee=assignee)) + TicketAssignee.objects.bulk_create(ticket_assignees) # action changed def open(self, applicant): self.applicant = applicant - self._change_action(action=TicketActionChoices.open.value) + self._change_action(TicketAction.open) + + def update_current_step_state_and_assignee(self, processor, state): + if self.status_closed: + raise AlreadyClosed + self.state = state + current_node = self.current_node + current_node.update(state=state) + current_node.first().ticket_assignees.filter(assignee=processor).update(state=state) def approve(self, processor): - self.processor = processor - self._change_action(action=TicketActionChoices.approve.value) + self.update_current_step_state_and_assignee(processor, TicketState.approved) + self._change_action(TicketAction.approve) def reject(self, processor): - self.processor = processor - self._change_action(action=TicketActionChoices.reject.value) + self.update_current_step_state_and_assignee(processor, TicketState.rejected) + self._change_action(TicketAction.reject) def close(self, processor): - self.processor = processor - self._change_action(action=TicketActionChoices.close.value) + self.update_current_step_state_and_assignee(processor, TicketState.closed) + self._change_action(TicketAction.close) def _change_action(self, action): - self.action = action self.save() post_change_ticket_action.send(sender=self.__class__, ticket=self, action=action) # ticket def has_assignee(self, assignee): - return self.assignees.filter(id=assignee.id).exists() + return self.ticket_steps.filter(ticket_assignees__assignee=assignee, level=self.approval_step).exists() @classmethod def get_user_related_tickets(cls, user): - queries = Q(applicant=user) | Q(assignees=user) + queries = Q(applicant=user) | Q(ticket_steps__ticket_assignees__assignee=user) tickets = cls.all().filter(queries).distinct() return tickets + def get_current_ticket_flow_approve(self): + return self.flow.rules.filter(level=self.approval_step).first() + @classmethod def all(cls): with tmp_to_root_org(): diff --git a/apps/tickets/permissions/ticket.py b/apps/tickets/permissions/ticket.py index dbc74e6a9..bd77421a8 100644 --- a/apps/tickets/permissions/ticket.py +++ b/apps/tickets/permissions/ticket.py @@ -1,4 +1,3 @@ - from rest_framework import permissions @@ -7,12 +6,7 @@ class IsAssignee(permissions.BasePermission): return obj.has_assignee(request.user) -class IsAssigneeOrApplicant(IsAssignee): +class IsApplicant(permissions.BasePermission): def has_object_permission(self, request, view, obj): - return super().has_object_permission(request, view, obj) or obj.applicant == request.user - - -class NotClosed(permissions.BasePermission): - def has_object_permission(self, request, view, obj): - return not obj.status_closed + return obj.applicant == request.user diff --git a/apps/tickets/serializers/__init__.py b/apps/tickets/serializers/__init__.py index 6b519ef80..4f7ca772b 100644 --- a/apps/tickets/serializers/__init__.py +++ b/apps/tickets/serializers/__init__.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- # from .ticket import * -from .assignee import * from .comment import * diff --git a/apps/tickets/serializers/assignee.py b/apps/tickets/serializers/assignee.py deleted file mode 100644 index 217c26fa9..000000000 --- a/apps/tickets/serializers/assignee.py +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework import serializers - -__all__ = ['AssigneeSerializer'] - - -class AssigneeSerializer(serializers.Serializer): - id = serializers.UUIDField() - name = serializers.CharField() - username = serializers.CharField() diff --git a/apps/tickets/serializers/comment.py b/apps/tickets/serializers/comment.py index 5bf57180e..6cb8ab9d5 100644 --- a/apps/tickets/serializers/comment.py +++ b/apps/tickets/serializers/comment.py @@ -26,7 +26,7 @@ class CommentSerializer(serializers.ModelSerializer): 'body', 'user_display', 'date_created', 'date_updated' ] - fields_fk = ['ticket', 'user',] + fields_fk = ['ticket', 'user', ] fields = fields_small + fields_fk read_only_fields = [ 'user_display', 'date_created', 'date_updated' diff --git a/apps/tickets/serializers/ticket/meta/meta.py b/apps/tickets/serializers/ticket/meta/meta.py index 12b576857..936977dfc 100644 --- a/apps/tickets/serializers/ticket/meta/meta.py +++ b/apps/tickets/serializers/ticket/meta/meta.py @@ -1,6 +1,7 @@ from tickets import const from .ticket_type import ( - apply_asset, apply_application, login_confirm, login_asset_confirm, command_confirm + apply_asset, apply_application, login_confirm, + login_asset_confirm, command_confirm ) __all__ = [ @@ -10,35 +11,31 @@ __all__ = [ # ticket action # ------------- -action_open = const.TicketActionChoices.open.value -action_approve = const.TicketActionChoices.approve.value +action_open = const.TicketAction.open.value +action_approve = const.TicketAction.approve.value # defines `meta` field dynamic mapping serializers # ------------------------------------------------ type_serializer_classes_mapping = { - const.TicketTypeChoices.apply_asset.value: { - 'default': apply_asset.ApplyAssetSerializer, - action_open: apply_asset.ApplySerializer, - action_approve: apply_asset.ApproveSerializer, + const.TicketType.apply_asset.value: { + 'default': apply_asset.ApplySerializer }, - const.TicketTypeChoices.apply_application.value: { - 'default': apply_application.ApplyApplicationSerializer, - action_open: apply_application.ApplySerializer, - action_approve: apply_application.ApproveSerializer, + const.TicketType.apply_application.value: { + 'default': apply_application.ApplySerializer }, - const.TicketTypeChoices.login_confirm.value: { + const.TicketType.login_confirm.value: { 'default': login_confirm.LoginConfirmSerializer, action_open: login_confirm.ApplySerializer, action_approve: login_confirm.LoginConfirmSerializer(read_only=True), }, - const.TicketTypeChoices.login_asset_confirm.value: { + const.TicketType.login_asset_confirm.value: { 'default': login_asset_confirm.LoginAssetConfirmSerializer, action_open: login_asset_confirm.ApplySerializer, action_approve: login_asset_confirm.LoginAssetConfirmSerializer(read_only=True), }, - const.TicketTypeChoices.command_confirm.value: { + const.TicketType.command_confirm.value: { 'default': command_confirm.CommandConfirmSerializer, action_open: command_confirm.ApplySerializer, action_approve: command_confirm.CommandConfirmSerializer(read_only=True) diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py b/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py index 382a8d789..e8217bf2b 100644 --- a/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py +++ b/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py @@ -1,20 +1,20 @@ from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ -from django.db.models import Q from perms.models import ApplicationPermission -from applications.models import Application from applications.const import AppCategory, AppType -from assets.models import SystemUser from orgs.utils import tmp_to_org from tickets.models import Ticket from .common import DefaultPermissionName __all__ = [ - 'ApplyApplicationSerializer', 'ApplySerializer', 'ApproveSerializer', + 'ApplySerializer', ] class ApplySerializer(serializers.Serializer): + apply_permission_name = serializers.CharField( + max_length=128, default=DefaultPermissionName(), label=_('Apply name') + ) # 申请信息 apply_category = serializers.ChoiceField( required=True, choices=AppCategory.choices, label=_('Category'), @@ -31,13 +31,23 @@ class ApplySerializer(serializers.Serializer): required=False, read_only=True, label=_('Type display'), allow_null=True ) - apply_application_group = serializers.ListField( - required=False, child=serializers.CharField(), label=_('Application group'), - default=list, allow_null=True + apply_applications = serializers.ListField( + required=True, child=serializers.UUIDField(), label=_('Apply applications'), + allow_null=True ) - apply_system_user_group = serializers.ListField( - required=False, child=serializers.CharField(), label=_('System user group'), - default=list, allow_null=True + apply_applications_display = serializers.ListField( + required=False, read_only=True, child=serializers.CharField(), + label=_('Apply applications display'), allow_null=True, + default=list + ) + apply_system_users = serializers.ListField( + required=True, child=serializers.UUIDField(), label=_('Apply system users'), + allow_null=True + ) + apply_system_users_display = serializers.ListField( + required=False, read_only=True, child=serializers.CharField(), + label=_('Apply system user display'), allow_null=True, + default=list ) apply_date_start = serializers.DateTimeField( required=True, label=_('Date start'), allow_null=True @@ -46,37 +56,6 @@ class ApplySerializer(serializers.Serializer): required=True, label=_('Date expired'), allow_null=True ) - -class ApproveSerializer(serializers.Serializer): - # 审批信息 - approve_permission_name = serializers.CharField( - max_length=128, default=DefaultPermissionName(), label=_('Permission name') - ) - approve_applications = serializers.ListField( - required=True, child=serializers.UUIDField(), label=_('Approve applications'), - allow_null=True - ) - approve_applications_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve applications display'), allow_null=True, - default=list - ) - approve_system_users = serializers.ListField( - required=True, child=serializers.UUIDField(), label=_('Approve system users'), - allow_null=True - ) - approve_system_users_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve system user display'), allow_null=True, - default=list - ) - approve_date_start = serializers.DateTimeField( - required=True, label=_('Date start'), allow_null=True - ) - approve_date_expired = serializers.DateTimeField( - required=True, label=_('Date expired'), allow_null=True - ) - def validate_approve_permission_name(self, permission_name): if not isinstance(self.root.instance, Ticket): return permission_name @@ -90,83 +69,5 @@ class ApproveSerializer(serializers.Serializer): 'Permission named `{}` already exists'.format(permission_name) )) - def validate_approve_applications(self, approve_applications): - if not isinstance(self.root.instance, Ticket): - return [] - - with tmp_to_org(self.root.instance.org_id): - apply_type = self.root.instance.meta.get('apply_type') - queries = Q(type=apply_type) - queries &= Q(id__in=approve_applications) - application_ids = Application.objects.filter(queries).values_list('id', flat=True) - application_ids = [str(application_id) for application_id in application_ids] - if application_ids: - return application_ids - - raise serializers.ValidationError(_( - 'No `Application` are found under Organization `{}`'.format(self.root.instance.org_name) - )) - - def validate_approve_system_users(self, approve_system_users): - if not isinstance(self.root.instance, Ticket): - return [] - - with tmp_to_org(self.root.instance.org_id): - apply_type = self.root.instance.meta.get('apply_type') - protocol = SystemUser.get_protocol_by_application_type(apply_type) - queries = Q(protocol=protocol) - queries &= Q(id__in=approve_system_users) - system_user_ids = SystemUser.objects.filter(queries).values_list('id', flat=True) - system_user_ids = [str(system_user_id) for system_user_id in system_user_ids] - if system_user_ids: - return system_user_ids - - raise serializers.ValidationError(_( - 'No `SystemUser` are found under Organization `{}`'.format(self.root.instance.org_name) - )) -class ApplyApplicationSerializer(ApplySerializer, ApproveSerializer): - # 推荐信息 - recommend_applications = serializers.SerializerMethodField() - recommend_system_users = serializers.SerializerMethodField() - - def get_recommend_applications(self, value): - if not isinstance(self.root.instance, Ticket): - return [] - - apply_application_group = value.get('apply_application_group', []) - if not apply_application_group: - return [] - - apply_type = value.get('apply_type') - queries = Q() - for application in apply_application_group: - queries |= Q(name__icontains=application) - queries &= Q(type=apply_type) - - with tmp_to_org(self.root.instance.org_id): - application_ids = Application.objects.filter(queries).values_list('id', flat=True)[:15] - application_ids = [str(application_id) for application_id in application_ids] - return application_ids - - def get_recommend_system_users(self, value): - if not isinstance(self.root.instance, Ticket): - return [] - - apply_system_user_group = value.get('apply_system_user_group', []) - if not apply_system_user_group: - return [] - - apply_type = value.get('apply_type') - protocol = SystemUser.get_protocol_by_application_type(apply_type) - queries = Q() - for system_user in apply_system_user_group: - queries |= Q(username__icontains=system_user) - queries |= Q(name__icontains=system_user) - queries &= Q(protocol=protocol) - - with tmp_to_org(self.root.instance.org_id): - system_user_ids = SystemUser.objects.filter(queries).values_list('id', flat=True)[:5] - system_user_ids = [str(system_user_id) for system_user_id in system_user_ids] - return system_user_ids diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py b/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py index 2ed9107c8..489ded1a8 100644 --- a/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py +++ b/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py @@ -1,39 +1,44 @@ from django.utils.translation import ugettext_lazy as _ -from django.db.models import Q from rest_framework import serializers from perms.serializers import ActionsField from perms.models import AssetPermission -from assets.models import Asset, SystemUser from orgs.utils import tmp_to_org from tickets.models import Ticket from .common import DefaultPermissionName - __all__ = [ - 'ApplyAssetSerializer', 'ApplySerializer', 'ApproveSerializer', + 'ApplySerializer', ] class ApplySerializer(serializers.Serializer): + apply_permission_name = serializers.CharField( + max_length=128, default=DefaultPermissionName(), label=_('Apply name') + ) # 申请信息 - apply_ip_group = serializers.ListField( - required=False, child=serializers.IPAddressField(), label=_('IP group'), - default=list, allow_null=True, + apply_assets = serializers.ListField( + required=True, allow_null=True, child=serializers.UUIDField(), label=_('Apply assets') ) - apply_hostname_group = serializers.ListField( - required=False, child=serializers.CharField(), label=_('Hostname group'), - default=list, allow_null=True, + apply_assets_display = serializers.ListField( + required=False, read_only=True, child=serializers.CharField(), + label=_('Approve assets display'), allow_null=True, + default=list, ) - apply_system_user_group = serializers.ListField( - required=False, child=serializers.CharField(), label=_('System user group'), - default=list, allow_null=True + apply_system_users = serializers.ListField( + required=True, allow_null=True, child=serializers.UUIDField(), + label=_('Approve system users') + ) + apply_system_users_display = serializers.ListField( + required=False, read_only=True, child=serializers.CharField(), + label=_('Apply assets display'), allow_null=True, + default=list, ) apply_actions = ActionsField( required=True, allow_null=True ) apply_actions_display = serializers.ListField( required=False, read_only=True, child=serializers.CharField(), - label=_('Approve assets display'), allow_null=True, + label=_('Apply assets display'), allow_null=True, default=list, ) apply_date_start = serializers.DateTimeField( @@ -43,44 +48,6 @@ class ApplySerializer(serializers.Serializer): required=True, label=_('Date expired'), allow_null=True, ) - -class ApproveSerializer(serializers.Serializer): - # 审批信息 - approve_permission_name = serializers.CharField( - max_length=128, default=DefaultPermissionName(), label=_('Permission name') - ) - approve_assets = serializers.ListField( - required=True, allow_null=True, child=serializers.UUIDField(), label=_('Approve assets') - ) - approve_assets_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve assets display'), allow_null=True, - default=list, - ) - approve_system_users = serializers.ListField( - required=True, allow_null=True, child=serializers.UUIDField(), - label=_('Approve system users') - ) - approve_system_users_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve assets display'), allow_null=True, - default=list, - ) - approve_actions = ActionsField( - required=True, allow_null=True, - ) - approve_actions_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve assets display'), allow_null=True, - default=list, - ) - approve_date_start = serializers.DateTimeField( - required=True, label=_('Date start'), allow_null=True, - ) - approve_date_expired = serializers.DateTimeField( - required=True, label=_('Date expired'), allow_null=True - ) - def validate_approve_permission_name(self, permission_name): if not isinstance(self.root.instance, Ticket): return permission_name @@ -93,76 +60,3 @@ class ApproveSerializer(serializers.Serializer): raise serializers.ValidationError(_( 'Permission named `{}` already exists'.format(permission_name) )) - - def validate_approve_assets(self, approve_assets): - if not isinstance(self.root.instance, Ticket): - return [] - - with tmp_to_org(self.root.instance.org_id): - asset_ids = Asset.objects.filter(id__in=approve_assets).values_list('id', flat=True) - asset_ids = [str(asset_id) for asset_id in asset_ids] - if asset_ids: - return asset_ids - - raise serializers.ValidationError(_( - 'No `Asset` are found under Organization `{}`'.format(self.root.instance.org_name) - )) - - def validate_approve_system_users(self, approve_system_users): - if not isinstance(self.root.instance, Ticket): - return [] - - with tmp_to_org(self.root.instance.org_id): - queries = Q(protocol__in=SystemUser.ASSET_CATEGORY_PROTOCOLS) - queries &= Q(id__in=approve_system_users) - system_user_ids = SystemUser.objects.filter(queries).values_list('id', flat=True) - system_user_ids = [str(system_user_id) for system_user_id in system_user_ids] - if system_user_ids: - return system_user_ids - - raise serializers.ValidationError(_( - 'No `SystemUser` are found under Organization `{}`'.format(self.root.instance.org_name) - )) - - -class ApplyAssetSerializer(ApplySerializer, ApproveSerializer): - # 推荐信息 - recommend_assets = serializers.SerializerMethodField() - recommend_system_users = serializers.SerializerMethodField() - - def get_recommend_assets(self, value): - if not isinstance(self.root.instance, Ticket): - return [] - - apply_ip_group = value.get('apply_ip_group', []) - apply_hostname_group = value.get('apply_hostname_group', []) - queries = Q() - if apply_ip_group: - queries |= Q(ip__in=apply_ip_group) - for hostname in apply_hostname_group: - queries |= Q(hostname__icontains=hostname) - if not queries: - return [] - with tmp_to_org(self.root.instance.org_id): - asset_ids = Asset.objects.filter(queries).values_list('id', flat=True)[:100] - asset_ids = [str(asset_id) for asset_id in asset_ids] - return asset_ids - - def get_recommend_system_users(self, value): - if not isinstance(self.root.instance, Ticket): - return [] - - apply_system_user_group = value.get('apply_system_user_group', []) - if not apply_system_user_group: - return [] - - queries = Q() - for system_user in apply_system_user_group: - queries |= Q(username__icontains=system_user) - queries |= Q(name__icontains=system_user) - queries &= Q(protocol__in=SystemUser.ASSET_CATEGORY_PROTOCOLS) - - with tmp_to_org(self.root.instance.org_id): - system_user_ids = SystemUser.objects.filter(queries).values_list('id', flat=True)[:5] - system_user_ids = [str(system_user_id) for system_user_id in system_user_ids] - return system_user_ids diff --git a/apps/tickets/serializers/ticket/ticket.py b/apps/tickets/serializers/ticket/ticket.py index fdf281b73..ca5f6add1 100644 --- a/apps/tickets/serializers/ticket/ticket.py +++ b/apps/tickets/serializers/ticket/ticket.py @@ -1,43 +1,38 @@ # -*- coding: utf-8 -*- # from django.utils.translation import ugettext_lazy as _ +from django.db.transaction import atomic from rest_framework import serializers from common.drf.serializers import MethodSerializer from orgs.mixins.serializers import OrgResourceModelSerializerMixin +from perms.models import AssetPermission from orgs.models import Organization +from orgs.utils import tmp_to_org from users.models import User -from tickets.models import Ticket +from tickets.models import Ticket, TicketFlow, ApprovalRule +from tickets.const import TicketApprovalStrategy from .meta import type_serializer_classes_mapping - __all__ = [ - 'TicketDisplaySerializer', 'TicketApplySerializer', 'TicketApproveSerializer', + 'TicketDisplaySerializer', 'TicketApplySerializer', 'TicketApproveSerializer', 'TicketFlowSerializer' ] class TicketSerializer(OrgResourceModelSerializerMixin): type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) - action_display = serializers.ReadOnlyField( - source='get_action_display', label=_('Action display') - ) - status_display = serializers.ReadOnlyField( - source='get_status_display', label=_('Status display') - ) + status_display = serializers.ReadOnlyField(source='get_status_display', label=_('Status display')) meta = MethodSerializer() class Meta: model = Ticket fields_mini = ['id', 'title'] fields_small = fields_mini + [ - 'type', 'type_display', 'meta', 'body', - 'action', 'action_display', 'status', 'status_display', - 'applicant_display', 'processor_display', 'assignees_display', - 'date_created', 'date_updated', - 'comment', 'org_id', 'org_name', + 'type', 'type_display', 'meta', 'state', 'approval_step', + 'status', 'status_display', 'applicant_display', 'process_map', + 'date_created', 'date_updated', 'comment', 'org_id', 'org_name', 'body' ] - fields_fk = ['applicant', 'processor',] - fields_m2m = ['assignees'] - fields = fields_small + fields_fk + fields_m2m + fields_fk = ['applicant', ] + fields = fields_small + fields_fk def get_meta_serializer(self): default_serializer = serializers.Serializer(read_only=True) @@ -71,7 +66,6 @@ class TicketSerializer(OrgResourceModelSerializerMixin): class TicketDisplaySerializer(TicketSerializer): - class Meta: model = Ticket fields = TicketSerializer.Meta.fields @@ -87,7 +81,7 @@ class TicketApplySerializer(TicketSerializer): model = Ticket fields = TicketSerializer.Meta.fields writeable_fields = [ - 'id', 'title', 'type', 'meta', 'assignees', 'comment', 'org_id' + 'id', 'title', 'type', 'meta', 'comment', 'org_id' ] read_only_fields = list(set(fields) - set(writeable_fields)) extra_kwargs = { @@ -112,27 +106,115 @@ class TicketApplySerializer(TicketSerializer): raise serializers.ValidationError(error) return org_id - def validate_assignees(self, assignees): - org_id = self.initial_data.get('org_id') - self.validate_org_id(org_id) - org = Organization.get_instance(org_id) - admins = User.get_super_and_org_admins(org) - valid_assignees = list(set(assignees) & set(admins)) - if not valid_assignees: - error = _('None of the assignees belong to Organization `{}` admins'.format(org.name)) + def validate(self, attrs): + ticket_type = attrs.get('type') + flow = TicketFlow.get_org_related_flows().filter(type=ticket_type).first() + if flow: + attrs['flow'] = flow + else: + error = _('The ticket flow `{}` does not exist'.format(ticket_type)) raise serializers.ValidationError(error) - return valid_assignees + return attrs + + @atomic + def create(self, validated_data): + instance = super().create(validated_data) + name = _('Created by ticket ({}-{})').format(instance.title, str(instance.id)[:4]) + with tmp_to_org(instance.org_id): + if not AssetPermission.objects.filter(name=name).exists(): + instance.meta.update({'apply_permission_name': name}) + return instance + raise serializers.ValidationError(_('Permission named `{}` already exists'.format(name))) class TicketApproveSerializer(TicketSerializer): + meta = serializers.ReadOnlyField() class Meta: model = Ticket fields = TicketSerializer.Meta.fields - writeable_fields = ['meta'] - read_only_fields = list(set(fields) - set(writeable_fields)) + read_only_fields = fields - def validate_meta(self, meta): - _meta = self.instance.meta if self.instance else {} - _meta.update(meta) - return _meta + +class TicketFlowApproveSerializer(serializers.ModelSerializer): + strategy_display = serializers.ReadOnlyField(source='get_strategy_display', label=_('Approve strategy')) + assignees_read_only = serializers.SerializerMethodField(label=_("Assignees")) + + class Meta: + model = ApprovalRule + fields_small = [ + 'level', 'strategy', 'assignees_read_only', 'assignees_display', 'strategy_display' + ] + fields_m2m = ['assignees', ] + fields = fields_small + fields_m2m + read_only_fields = ['level', 'assignees_display'] + extra_kwargs = { + 'assignees': {'write_only': True, 'allow_empty': True} + } + + def get_assignees_read_only(self, obj): + if obj.strategy == TicketApprovalStrategy.custom_user: + return obj.assignees.values_list('id', flat=True) + return [] + + +class TicketFlowSerializer(OrgResourceModelSerializerMixin): + type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) + rules = TicketFlowApproveSerializer(many=True, required=True) + + class Meta: + model = TicketFlow + fields_mini = ['id', ] + fields_small = fields_mini + [ + 'type', 'type_display', 'approval_level', 'created_by', 'date_created', 'date_updated', + 'org_id', 'org_name' + ] + fields = fields_small + ['rules', ] + read_only_fields = ['created_by', 'org_id', 'date_created', 'date_updated'] + extra_kwargs = { + 'type': {'required': True}, + 'approval_level': {'required': True} + } + + def validate_type(self, value): + if not self.instance or (self.instance and self.instance.type != value): + if self.Meta.model.objects.filter(type=value).exists(): + error = _('The current organization type already exists') + raise serializers.ValidationError(error) + return value + + def create_or_update(self, action, validated_data, related, assignees, instance=None): + childs = validated_data.pop(related, []) + if not instance: + instance = getattr(super(), action)(validated_data) + else: + instance = getattr(super(), action)(instance, validated_data) + getattr(instance, related).all().delete() + instance_related = getattr(instance, related) + child_instances = [] + related_model = instance_related.model + for level, data in enumerate(childs, 1): + data_m2m = data.pop(assignees, None) + child_instance = related_model.objects.create(**data, level=level) + if child_instance.strategy == TicketApprovalStrategy.super_admin: + data_m2m = list(User.get_super_admins()) + elif child_instance.strategy == TicketApprovalStrategy.org_admin: + data_m2m = list(User.get_org_admins()) + elif child_instance.strategy == TicketApprovalStrategy.super_org_admin: + data_m2m = list(User.get_super_and_org_admins()) + getattr(child_instance, assignees).set(data_m2m) + child_instances.append(child_instance) + instance_related.set(child_instances) + return instance + + @atomic + def create(self, validated_data): + return self.create_or_update('create', validated_data, 'rules', 'assignees') + + @atomic + def update(self, instance, validated_data): + if instance.org_id == Organization.ROOT_ID: + instance = self.create(validated_data) + else: + instance = self.create_or_update('update', validated_data, 'rules', 'assignees', instance) + return instance diff --git a/apps/tickets/signals.py b/apps/tickets/signals.py index 10951716d..b626cfa35 100644 --- a/apps/tickets/signals.py +++ b/apps/tickets/signals.py @@ -1,4 +1,5 @@ from django.dispatch import Signal - post_change_ticket_action = Signal() + +post_or_update_change_ticket_flow_approval = Signal() diff --git a/apps/tickets/signals_handler/ticket.py b/apps/tickets/signals_handler/ticket.py index aae620295..2d036f936 100644 --- a/apps/tickets/signals_handler/ticket.py +++ b/apps/tickets/signals_handler/ticket.py @@ -3,9 +3,8 @@ from django.dispatch import receiver from common.utils import get_logger -from tickets.models import Ticket -from ..signals import post_change_ticket_action - +from tickets.models import Ticket, ApprovalRule +from ..signals import post_change_ticket_action, post_or_update_change_ticket_flow_approval logger = get_logger(__name__) @@ -13,3 +12,12 @@ logger = get_logger(__name__) @receiver(post_change_ticket_action, sender=Ticket) def on_post_change_ticket_action(sender, ticket, action, **kwargs): ticket.handler.dispatch(action) + + +@receiver(post_or_update_change_ticket_flow_approval, sender=ApprovalRule) +def post_or_update_change_ticket_flow_approval(sender, qs, **kwargs): + updates = [] + for instance in qs: + instance.assignees_display = [str(assignee) for assignee in instance.assignees.all()] + updates.append(instance) + sender.objects.bulk_update(updates, ['assignees_display', ]) diff --git a/apps/tickets/urls/api_urls.py b/apps/tickets/urls/api_urls.py index 93286f645..ef72013a1 100644 --- a/apps/tickets/urls/api_urls.py +++ b/apps/tickets/urls/api_urls.py @@ -8,7 +8,7 @@ app_name = 'tickets' router = BulkRouter() router.register('tickets', api.TicketViewSet, 'ticket') -router.register('assignees', api.AssigneeViewSet, 'assignee') +router.register('flows', api.TicketFlowViewSet, 'flows') router.register('comments', api.CommentViewSet, 'comment') urlpatterns = [] diff --git a/apps/tickets/utils.py b/apps/tickets/utils.py index 1bd789fda..7999a5e1d 100644 --- a/apps/tickets/utils.py +++ b/apps/tickets/utils.py @@ -26,9 +26,10 @@ EMAIL_TEMPLATE = ''' def send_ticket_applied_mail_to_assignees(ticket): - if not ticket.assignees: + assignees = ticket.current_node.first().ticket_assignees.all() + if not assignees: logger.debug("Not found assignees, ticket: {}({}), assignees: {}".format( - ticket, str(ticket.id), ticket.assignees) + ticket, str(ticket.id), assignees) ) return @@ -42,24 +43,24 @@ def send_ticket_applied_mail_to_assignees(ticket): ) if settings.DEBUG: logger.debug(message) - recipient_list = [assignee.email for assignee in ticket.assignees.all()] + recipient_list = [i.assignee.email for i in assignees] send_mail_async.delay(subject, message, recipient_list, html_message=message) -def send_ticket_processed_mail_to_applicant(ticket): +def send_ticket_processed_mail_to_applicant(ticket, processor): if not ticket.applicant: logger.error("Not found applicant: {}({})".format(ticket.title, ticket.id)) return - + processor_display = str(processor) ticket_detail_url = urljoin(settings.SITE_URL, const.TICKET_DETAIL_URL.format(id=str(ticket.id))) - subject = _('Ticket has processed - {} ({})').format(ticket.title, ticket.processor_display) + subject = _('Ticket has processed - {} ({})').format(ticket.title, processor_display) message = EMAIL_TEMPLATE.format( - title=_('Your ticket has been processed, processor - {}').format(ticket.processor_display), + title=_('Your ticket has been processed, processor - {}').format(processor_display), ticket_detail_url=ticket_detail_url, ticket_detail_url_description=_('click here to review'), body=ticket.body.replace('\n', '
'), ) if settings.DEBUG: logger.debug(message) - recipient_list = [ticket.applicant.email] + recipient_list = [ticket.applicant.email, ] send_mail_async.delay(subject, message, recipient_list, html_message=message) diff --git a/apps/users/api/profile.py b/apps/users/api/profile.py index 9dcb42515..02bdda07c 100644 --- a/apps/users/api/profile.py +++ b/apps/users/api/profile.py @@ -4,14 +4,13 @@ import uuid from rest_framework import generics from common.permissions import IsOrgAdmin from rest_framework.permissions import IsAuthenticated -from django.conf import settings +from users.notifications import ResetPasswordMsg, ResetPasswordSuccessMsg, ResetSSHKeyMsg from common.permissions import ( IsCurrentUserOrReadOnly ) from .. import serializers from ..models import User -from ..utils import send_reset_password_success_mail from .mixins import UserQuerysetMixin __all__ = [ @@ -29,11 +28,10 @@ class UserResetPasswordApi(UserQuerysetMixin, generics.UpdateAPIView): def perform_update(self, serializer): # Note: we are not updating the user object here. # We just do the reset-password stuff. - from ..utils import send_reset_password_mail user = self.get_object() user.password_raw = str(uuid.uuid4()) user.save() - send_reset_password_mail(user) + ResetPasswordMsg(user).publish_async() class UserResetPKApi(UserQuerysetMixin, generics.UpdateAPIView): @@ -41,11 +39,11 @@ class UserResetPKApi(UserQuerysetMixin, generics.UpdateAPIView): permission_classes = (IsOrgAdmin,) def perform_update(self, serializer): - from ..utils import send_reset_ssh_key_mail user = self.get_object() user.public_key = None user.save() - send_reset_ssh_key_mail(user) + + ResetSSHKeyMsg(user).publish_async() # 废弃 @@ -84,4 +82,4 @@ class UserPublicKeyApi(generics.RetrieveUpdateAPIView): def perform_update(self, serializer): super().perform_update(serializer) - send_reset_password_success_mail(self.request, self.get_object()) + ResetPasswordSuccessMsg(self.get_object(), self.request).publish_async() diff --git a/apps/users/api/user.py b/apps/users/api/user.py index edd9b396b..f55a96964 100644 --- a/apps/users/api/user.py +++ b/apps/users/api/user.py @@ -8,6 +8,7 @@ from rest_framework.response import Response from rest_framework_bulk import BulkModelViewSet from django.db.models import Prefetch +from users.notifications import ResetMFAMsg from common.permissions import ( IsOrgAdmin, IsOrgAdminOrAppUser, CanUpdateDeleteUser, IsSuperUser @@ -16,7 +17,7 @@ from common.mixins import CommonApiMixin from common.utils import get_logger from orgs.utils import current_org from orgs.models import ROLE as ORG_ROLE, OrganizationMember -from users.utils import send_reset_mfa_mail, LoginBlockUtil, MFABlockUtils +from users.utils import LoginBlockUtil, MFABlockUtils from .. import serializers from ..serializers import UserSerializer, UserRetrieveSerializer, MiniUserSerializer, InviteSerializer from .mixins import UserQuerysetMixin @@ -130,14 +131,7 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet): @action(methods=['get'], detail=False, permission_classes=(IsOrgAdmin,)) def suggestion(self, request): queryset = User.objects.exclude(role=User.ROLE.APP) - queryset = self.filter_queryset(queryset) - queryset = queryset[:3] - - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - return self.get_paginated_response(serializer.data) - + queryset = self.filter_queryset(queryset)[:3] serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) @@ -216,5 +210,6 @@ class UserResetOTPApi(UserQuerysetMixin, generics.RetrieveAPIView): if user.mfa_enabled: user.reset_mfa() user.save() - send_reset_mfa_mail(user) + + ResetMFAMsg(user).publish_async() return Response({"msg": "success"}) diff --git a/apps/users/exceptions.py b/apps/users/exceptions.py index ff873d3dc..e69e65966 100644 --- a/apps/users/exceptions.py +++ b/apps/users/exceptions.py @@ -8,3 +8,13 @@ class MFANotEnabled(JMSException): status_code = status.HTTP_403_FORBIDDEN default_code = 'mfa_not_enabled' default_detail = _('MFA not enabled') + + +class PhoneNotSet(JMSException): + default_code = 'phone_not_set' + default_detail = _('Phone not set') + + +class MFAMethodNotSupport(JMSException): + default_code = 'mfa_not_support' + default_detail = _('MFA method not support') diff --git a/apps/users/forms/profile.py b/apps/users/forms/profile.py index 63c1078d6..d2b005e12 100644 --- a/apps/users/forms/profile.py +++ b/apps/users/forms/profile.py @@ -19,7 +19,7 @@ __all__ = [ class UserCheckPasswordForm(forms.Form): password = forms.CharField( label=_('Password'), widget=forms.PasswordInput, - max_length=128, strip=False + max_length=1024, strip=False ) diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 836e0b383..9911bf91b 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -20,19 +20,24 @@ from django.shortcuts import reverse from orgs.utils import current_org from orgs.models import OrganizationMember, Organization +from common.exceptions import JMSException from common.utils import date_expired_default, get_logger, lazyproperty, random_string from common import fields from common.const import choices from common.db.models import TextChoices -from users.exceptions import MFANotEnabled +from users.exceptions import MFANotEnabled, PhoneNotSet from ..signals import post_user_change_password - -__all__ = ['User', 'UserPasswordHistory'] +__all__ = ['User', 'UserPasswordHistory', 'MFAType'] logger = get_logger(__file__) +class MFAType(TextChoices): + OTP = 'otp', _('One-time password') + SMS_CODE = 'sms', _('SMS verify code') + + class AuthMixin: date_password_last_updated: datetime.datetime is_local: bool @@ -369,12 +374,9 @@ class RoleMixin: @classmethod def get_super_and_org_admins(cls, org=None): super_admins = cls.get_super_admins() - super_admin_ids = list(super_admins.values_list('id', flat=True)) - org_admins = cls.get_org_admins(org) - org_admin_ids = list(org_admins.values_list('id', flat=True)) - admin_ids = set(org_admin_ids + super_admin_ids) - admins = User.objects.filter(id__in=admin_ids) - return admins + org_admins = cls.get_org_admins(org=org) + admins = org_admins | super_admins + return admins.distinct() class TokenMixin: @@ -518,21 +520,62 @@ class MFAMixin: from ..utils import check_otp_code return check_otp_code(self.otp_secret_key, code) - def check_mfa(self, code): + def check_mfa(self, code, mfa_type=MFAType.OTP): if not self.mfa_enabled: raise MFANotEnabled - if settings.OTP_IN_RADIUS: - return self.check_radius(code) - else: - return self.check_otp(code) + if mfa_type == MFAType.OTP: + if settings.OTP_IN_RADIUS: + return self.check_radius(code) + else: + return self.check_otp(code) + elif mfa_type == MFAType.SMS_CODE: + return self.check_sms_code(code) + + def get_supported_mfa_types(self): + methods = [] + if self.otp_secret_key: + methods.append(MFAType.OTP) + if self.phone: + methods.append(MFAType.SMS_CODE) + return methods + + def check_sms_code(self, code): + from authentication.sms_verify_code import VerifyCodeUtil + + if not self.phone: + raise PhoneNotSet + + try: + util = VerifyCodeUtil(self.phone) + return util.verify(code) + except JMSException: + return False + + def send_sms_code(self): + from authentication.sms_verify_code import VerifyCodeUtil + + if not self.phone: + raise PhoneNotSet + + util = VerifyCodeUtil(self.phone) + util.touch() + return util.timeout def mfa_enabled_but_not_set(self): if not self.mfa_enabled: return False, None - if self.mfa_is_otp() and not self.otp_secret_key: - return True, reverse('authentication:user-otp-enable-start') - return False, None + + if not self.mfa_is_otp(): + return False, None + + if self.mfa_is_otp() and self.otp_secret_key: + return False, None + + if self.phone and settings.SMS_ENABLED and settings.XPACK_ENABLED: + return False, None + + return True, reverse('authentication:user-otp-enable-start') class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): @@ -626,6 +669,10 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): group_ids = list(group_ids) return group_ids + @property + def receive_backends(self): + return self.user_msg_subscription.receive_backends + @property def is_wecom_bound(self): return bool(self.wecom_id) diff --git a/apps/users/notifications.py b/apps/users/notifications.py new file mode 100644 index 000000000..7d860ca14 --- /dev/null +++ b/apps/users/notifications.py @@ -0,0 +1,389 @@ +from datetime import datetime + +from django.utils.translation import ugettext as _ + +from common.utils import reverse, get_request_ip_or_data, get_request_user_agent, lazyproperty +from notifications.notifications import UserMessage + + +class BaseUserMessage(UserMessage): + def get_text_msg(self) -> dict: + raise NotImplementedError + + def get_html_msg(self) -> dict: + raise NotImplementedError + + @lazyproperty + def text_msg(self) -> dict: + return self.get_text_msg() + + @lazyproperty + def html_msg(self) -> dict: + return self.get_html_msg() + + def get_dingtalk_msg(self) -> dict: + return self.text_msg + + def get_wecom_msg(self) -> dict: + return self.text_msg + + def get_feishu_msg(self) -> dict: + return self.text_msg + + def get_email_msg(self) -> dict: + return self.html_msg + + def get_site_msg_msg(self) -> dict: + return self.html_msg + + +class ResetPasswordMsg(BaseUserMessage): + def get_text_msg(self) -> dict: + user = self.user + subject = _('Reset password') + message = _(""" +Hello %(name)s: +Please click the link below to reset your password, if not your request, concern your account security + +Click here reset password 👇 +%(rest_password_url)s?token=%(rest_password_token)s + +This link is valid for 1 hour. After it expires, + +request new one 👇 +%(forget_password_url)s?email=%(email)s + +------------------- + +Login direct 👇 +%(login_url)s + +""") % { + 'name': user.name, + 'rest_password_url': reverse('authentication:reset-password', external=True), + 'rest_password_token': user.generate_reset_token(), + 'forget_password_url': reverse('authentication:forgot-password', external=True), + 'email': user.email, + 'login_url': reverse('authentication:login', external=True), + } + return { + 'subject': subject, + 'message': message + } + + def get_html_msg(self) -> dict: + user = self.user + subject = _('Reset password') + message = _(""" + Hello %(name)s: +
+ Please click the link below to reset your password, if not your request, concern your account security +
+ Click here reset password +
+ This link is valid for 1 hour. After it expires, request new one + +
+ --- + +
+ Login direct + +
+ """) % { + 'name': user.name, + 'rest_password_url': reverse('authentication:reset-password', external=True), + 'rest_password_token': user.generate_reset_token(), + 'forget_password_url': reverse('authentication:forgot-password', external=True), + 'email': user.email, + 'login_url': reverse('authentication:login', external=True), + } + return { + 'subject': subject, + 'message': message + } + + +class ResetPasswordSuccessMsg(BaseUserMessage): + def __init__(self, user, request): + super().__init__(user) + self.ip_address = get_request_ip_or_data(request) + self.browser = get_request_user_agent(request) + + def get_text_msg(self) -> dict: + user = self.user + + subject = _('Reset password success') + message = _(""" + +Hi %(name)s: + +Your JumpServer password has just been successfully updated. + +If the password update was not initiated by you, your account may have security issues. +It is recommended that you log on to the JumpServer immediately and change your password. + +If you have any questions, you can contact the administrator. + +------------------- + + +IP Address: %(ip_address)s +
+
+Browser: %(browser)s +
+ + """) % { + 'name': user.name, + 'ip_address': self.ip_address, + 'browser': self.browser, + } + return { + 'subject': subject, + 'message': message + } + + def get_html_msg(self) -> dict: + user = self.user + + subject = _('Reset password success') + message = _(""" + + Hi %(name)s: +
+ + +
+ Your JumpServer password has just been successfully updated. +
+ +
+ If the password update was not initiated by you, your account may have security issues. + It is recommended that you log on to the JumpServer immediately and change your password. +
+ +
+ If you have any questions, you can contact the administrator. +
+
+ --- +
+
+ IP Address: %(ip_address)s +
+
+ Browser: %(browser)s +
+ + """) % { + 'name': user.name, + 'ip_address': self.ip_address, + 'browser': self.browser, + } + return { + 'subject': subject, + 'message': message + } + + +class PasswordExpirationReminderMsg(BaseUserMessage): + def get_text_msg(self) -> dict: + user = self.user + + subject = _('Security notice') + message = _(""" +Hello %(name)s: + +Your password will expire in %(date_password_expired)s, + +For your account security, please click on the link below to update your password in time + +Click here update password 👇 +%(update_password_url)s + +If your password has expired, please click 👇 to apply for a password reset email. +%(forget_password_url)s?email=%(email)s + +------------------- + +Login direct 👇 +%(login_url)s + + """) % { + 'name': user.name, + 'date_password_expired': datetime.fromtimestamp(datetime.timestamp( + user.date_password_expired)).strftime('%Y-%m-%d %H:%M'), + 'update_password_url': reverse('users:user-password-update', external=True), + 'forget_password_url': reverse('authentication:forgot-password', external=True), + 'email': user.email, + 'login_url': reverse('authentication:login', external=True), + } + return { + 'subject': subject, + 'message': message + } + + def get_html_msg(self) -> dict: + user = self.user + + subject = _('Security notice') + message = _(""" + Hello %(name)s: +
+ Your password will expire in %(date_password_expired)s, +
+ For your account security, please click on the link below to update your password in time +
+ Click here update password +
+ If your password has expired, please click + Password expired + to apply for a password reset email. + +
+ --- + +
+ Login direct + +
+ """) % { + 'name': user.name, + 'date_password_expired': datetime.fromtimestamp(datetime.timestamp( + user.date_password_expired)).strftime('%Y-%m-%d %H:%M'), + 'update_password_url': reverse('users:user-password-update', external=True), + 'forget_password_url': reverse('authentication:forgot-password', external=True), + 'email': user.email, + 'login_url': reverse('authentication:login', external=True), + } + return { + 'subject': subject, + 'message': message + } + + +class UserExpirationReminderMsg(BaseUserMessage): + def get_text_msg(self) -> dict: + subject = _('Expiration notice') + message = _(""" +Hello %(name)s: + +Your account will expire in %(date_expired)s, + +In order not to affect your normal work, please contact the administrator for confirmation. + + """) % { + 'name': self.user.name, + 'date_expired': datetime.fromtimestamp(datetime.timestamp( + self.user.date_expired)).strftime('%Y-%m-%d %H:%M'), + } + return { + 'subject': subject, + 'message': message + } + + def get_html_msg(self) -> dict: + subject = _('Expiration notice') + message = _(""" + Hello %(name)s: +
+ Your account will expire in %(date_expired)s, +
+ In order not to affect your normal work, please contact the administrator for confirmation. +
+ """) % { + 'name': self.user.name, + 'date_expired': datetime.fromtimestamp(datetime.timestamp( + self.user.date_expired)).strftime('%Y-%m-%d %H:%M'), + } + return { + 'subject': subject, + 'message': message + } + + +class ResetSSHKeyMsg(BaseUserMessage): + def get_text_msg(self) -> dict: + subject = _('SSH Key Reset') + message = _(""" +Hello %(name)s: + +Your ssh public key has been reset by site administrator. +Please login and reset your ssh public key. + +Login direct 👇 +%(login_url)s + + """) % { + 'name': self.user.name, + 'login_url': reverse('authentication:login', external=True), + } + + return { + 'subject': subject, + 'message': message + } + + def get_html_msg(self) -> dict: + subject = _('SSH Key Reset') + message = _(""" + Hello %(name)s: +
+ Your ssh public key has been reset by site administrator. + Please login and reset your ssh public key. +
+ Login direct + +
+ """) % { + 'name': self.user.name, + 'login_url': reverse('authentication:login', external=True), + } + + return { + 'subject': subject, + 'message': message + } + + +class ResetMFAMsg(BaseUserMessage): + def get_text_msg(self) -> dict: + subject = _('MFA Reset') + message = _(""" +Hello %(name)s: + +Your MFA has been reset by site administrator. +Please login and reset your MFA. + +Login direct 👇 +%(login_url)s + + """) % { + 'name': self.user.name, + 'login_url': reverse('authentication:login', external=True), + } + return { + 'subject': subject, + 'message': message + } + + def get_html_msg(self) -> dict: + subject = _('MFA Reset') + message = _(""" + Hello %(name)s: +
+ Your MFA has been reset by site administrator. + Please login and reset your MFA. +
+ Login direct + +
+ """) % { + 'name': self.user.name, + 'login_url': reverse('authentication:login', external=True), + } + return { + 'subject': subject, + 'message': message + } diff --git a/apps/users/serializers/profile.py b/apps/users/serializers/profile.py index 09ab50cc0..74e8e460b 100644 --- a/apps/users/serializers/profile.py +++ b/apps/users/serializers/profile.py @@ -101,16 +101,17 @@ class UserProfileSerializer(UserSerializer): ) mfa_level = serializers.ChoiceField(choices=MFA_LEVEL_CHOICES, label=_('MFA'), required=False) guide_url = serializers.SerializerMethodField() + receive_backends = serializers.ListField(child=serializers.CharField()) class Meta(UserSerializer.Meta): fields = UserSerializer.Meta.fields + [ 'public_key_comment', 'public_key_hash_md5', 'admin_or_audit_orgs', 'current_org_roles', 'guide_url', 'user_all_orgs', 'is_org_admin', - 'is_superuser' + 'is_superuser', 'receive_backends', ] read_only_fields = [ - 'date_joined', 'last_login', 'created_by', 'source' + 'date_joined', 'last_login', 'created_by', 'source', 'receive_backends', ] extra_kwargs = dict(UserSerializer.Meta.extra_kwargs) extra_kwargs.update({ diff --git a/apps/users/serializers/user.py b/apps/users/serializers/user.py index d4abf8d34..85fd40359 100644 --- a/apps/users/serializers/user.py +++ b/apps/users/serializers/user.py @@ -181,7 +181,7 @@ class UserRetrieveSerializer(UserSerializer): class MiniUserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['id', 'name', 'username'] + fields = UserSerializer.Meta.fields_mini class InviteSerializer(serializers.Serializer): diff --git a/apps/users/tasks.py b/apps/users/tasks.py index fc938499b..58ce4e3ed 100644 --- a/apps/users/tasks.py +++ b/apps/users/tasks.py @@ -5,15 +5,14 @@ import sys from celery import shared_task from django.conf import settings +from users.notifications import PasswordExpirationReminderMsg from ops.celery.utils import ( create_or_update_celery_periodic_tasks, disable_celery_periodic_task ) from ops.celery.decorator import after_app_ready_start from common.utils import get_logger from .models import User -from .utils import ( - send_password_expiration_reminder_mail, send_user_expiration_reminder_mail -) +from users.notifications import UserExpirationReminderMsg from settings.utils import LDAPServerUtil, LDAPImportUtil @@ -30,7 +29,8 @@ def check_password_expired(): continue msg = "The user {} password expires in {} days" logger.info(msg.format(user, user.password_expired_remain_days)) - send_password_expiration_reminder_mail(user) + + PasswordExpirationReminderMsg(user).publish_async() @shared_task @@ -57,7 +57,8 @@ def check_user_expired(): continue msg = "The user {} will expires in {} days" logger.info(msg.format(user, user.expired_remain_days)) - send_user_expiration_reminder_mail(user) + + UserExpirationReminderMsg(user).publish_async() @shared_task @@ -92,8 +93,8 @@ def import_ldap_user(): def import_ldap_user_periodic(): if not settings.AUTH_LDAP: return + task_name = 'import_ldap_user_periodic' if not settings.AUTH_LDAP_SYNC_IS_PERIODIC: - task_name = sys._getframe().f_code.co_name disable_celery_periodic_task(task_name) return @@ -104,7 +105,7 @@ def import_ldap_user_periodic(): interval = None crontab = settings.AUTH_LDAP_SYNC_CRONTAB tasks = { - 'import_ldap_user_periodic': { + task_name: { 'task': import_ldap_user.name, 'interval': interval, 'crontab': crontab, diff --git a/apps/users/templates/users/user_otp_check_password.html b/apps/users/templates/users/user_otp_check_password.html index f83e03b78..2f04c4b33 100644 --- a/apps/users/templates/users/user_otp_check_password.html +++ b/apps/users/templates/users/user_otp_check_password.html @@ -7,14 +7,34 @@ {% endblock %} {% block content %} -
+ {% csrf_token %}
- + +
- + {% if 'password' in form.errors %}

{{ form.password.errors.as_text }}

{% endif %}
+ + {% endblock %} diff --git a/apps/users/utils.py b/apps/users/utils.py index 8c55a99e5..b89dacdb2 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -10,7 +10,6 @@ import time from django.conf import settings from django.utils.translation import ugettext as _ from django.core.cache import cache -from datetime import datetime from common.tasks import send_mail_async from common.utils import reverse, get_object_or_none, get_request_ip_or_data, get_request_user_agent @@ -79,184 +78,6 @@ def send_user_created_mail(user): send_mail_async.delay(subject, message, recipient_list, html_message=message) -def send_reset_password_mail(user): - subject = _('Reset password') - recipient_list = [user.email] - message = _(""" - Hello %(name)s: -
- Please click the link below to reset your password, if not your request, concern your account security -
- Click here reset password -
- This link is valid for 1 hour. After it expires, request new one - -
- --- - -
- Login direct - -
- """) % { - 'name': user.name, - 'rest_password_url': reverse('authentication:reset-password', external=True), - 'rest_password_token': user.generate_reset_token(), - 'forget_password_url': reverse('authentication:forgot-password', external=True), - 'email': user.email, - 'login_url': reverse('authentication:login', external=True), - } - if settings.DEBUG: - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - -def send_reset_password_success_mail(request, user): - subject = _('Reset password success') - recipient_list = [user.email] - message = _(""" - - Hi %(name)s: -
- - -
- Your JumpServer password has just been successfully updated. -
- -
- If the password update was not initiated by you, your account may have security issues. - It is recommended that you log on to the JumpServer immediately and change your password. -
- -
- If you have any questions, you can contact the administrator. -
-
- --- -
-
- IP Address: %(ip_address)s -
-
- Browser: %(browser)s -
- - """) % { - 'name': user.name, - 'ip_address': get_request_ip_or_data(request), - 'browser': get_request_user_agent(request), - } - if settings.DEBUG: - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - -def send_password_expiration_reminder_mail(user): - subject = _('Security notice') - recipient_list = [user.email] - message = _(""" - Hello %(name)s: -
- Your password will expire in %(date_password_expired)s, -
- For your account security, please click on the link below to update your password in time -
- Click here update password -
- If your password has expired, please click - Password expired - to apply for a password reset email. - -
- --- - -
- Login direct - -
- """) % { - 'name': user.name, - 'date_password_expired': datetime.fromtimestamp(datetime.timestamp( - user.date_password_expired)).strftime('%Y-%m-%d %H:%M'), - 'update_password_url': reverse('users:user-password-update', external=True), - 'forget_password_url': reverse('authentication:forgot-password', external=True), - 'email': user.email, - 'login_url': reverse('authentication:login', external=True), - } - if settings.DEBUG: - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - -def send_user_expiration_reminder_mail(user): - subject = _('Expiration notice') - recipient_list = [user.email] - message = _(""" - Hello %(name)s: -
- Your account will expire in %(date_expired)s, -
- In order not to affect your normal work, please contact the administrator for confirmation. -
- """) % { - 'name': user.name, - 'date_expired': datetime.fromtimestamp(datetime.timestamp( - user.date_expired)).strftime('%Y-%m-%d %H:%M'), - } - if settings.DEBUG: - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - -def send_reset_ssh_key_mail(user): - subject = _('SSH Key Reset') - recipient_list = [user.email] - message = _(""" - Hello %(name)s: -
- Your ssh public key has been reset by site administrator. - Please login and reset your ssh public key. -
- Login direct - -
- """) % { - 'name': user.name, - 'login_url': reverse('authentication:login', external=True), - } - if settings.DEBUG: - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - -def send_reset_mfa_mail(user): - subject = _('MFA Reset') - recipient_list = [user.email] - message = _(""" - Hello %(name)s: -
- Your MFA has been reset by site administrator. - Please login and reset your MFA. -
- Login direct - -
- """) % { - 'name': user.name, - 'login_url': reverse('authentication:login', external=True), - } - if settings.DEBUG: - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - def get_user_or_pre_auth_user(request): user = request.user if user.is_authenticated: diff --git a/apps/users/views/profile/password.py b/apps/users/views/profile/password.py index e2dde8e92..8c92487c1 100644 --- a/apps/users/views/profile/password.py +++ b/apps/users/views/profile/password.py @@ -6,6 +6,8 @@ from django.contrib.auth import authenticate from django.shortcuts import redirect from django.utils.translation import ugettext as _ from django.views.generic.edit import FormView +from authentication.mixins import PasswordEncryptionViewMixin +from authentication import errors from common.utils import get_logger from ... import forms @@ -18,13 +20,17 @@ __all__ = ['UserVerifyPasswordView'] logger = get_logger(__name__) -class UserVerifyPasswordView(FormView): +class UserVerifyPasswordView(PasswordEncryptionViewMixin, FormView): template_name = 'users/user_password_verify.html' form_class = forms.UserCheckPasswordForm def form_valid(self, form): user = get_user_or_pre_auth_user(self.request) - password = form.cleaned_data.get('password') + try: + password = self.get_decrypted_password(username=user.username) + except errors.AuthFailedError as e: + form.add_error("password", _(f"Password invalid") + f'({e.msg})') + return self.form_invalid(form) user = authenticate(request=self.request, username=user.username, password=password) if not user: form.add_error("password", _("Password invalid")) diff --git a/apps/users/views/profile/reset.py b/apps/users/views/profile/reset.py index 793322cc9..c3529c73c 100644 --- a/apps/users/views/profile/reset.py +++ b/apps/users/views/profile/reset.py @@ -9,13 +9,13 @@ from django.conf import settings from django.urls import reverse_lazy from django.views.generic import FormView +from users.notifications import ResetPasswordSuccessMsg, ResetPasswordMsg from common.utils import get_object_or_none, FlashMessageUtil from common.permissions import IsValidUser from common.mixins.views import PermissionsMixin from ...models import User from ...utils import ( - send_reset_password_mail, get_password_check_rules, check_password_rules, - send_reset_password_success_mail + get_password_check_rules, check_password_rules, ) from ... import forms @@ -59,7 +59,8 @@ class UserForgotPasswordView(FormView): ).format(user.get_source_display()) form.add_error('email', error) return self.form_invalid(form) - send_reset_password_mail(user) + + ResetPasswordMsg(user).publish_async() url = self.get_redirect_message_url() return redirect(url) @@ -115,7 +116,8 @@ class UserResetPasswordView(FormView): user.reset_password(password) User.expired_reset_password_token(token) - send_reset_password_success_mail(self.request, user) + + ResetPasswordSuccessMsg(user, self.request).publish_async() url = self.get_redirect_url() return redirect(url) diff --git a/config_example.yml b/config_example.yml index d2124df2e..dd509c0aa 100644 --- a/config_example.yml +++ b/config_example.yml @@ -1,5 +1,5 @@ # SECURITY WARNING: keep the secret key used in production secret! -# 加密秘钥 生产环境中请修改为随机字符串,请勿外泄, 可使用命令生成 +# 加密密钥 生产环境中请修改为随机字符串,请勿外泄, 可使用命令生成 # $ cat /dev/urandom | tr -dc A-Za-z0-9 | head -c 49;echo SECRET_KEY: diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 21be4262c..aaa1b662a 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -59,7 +59,7 @@ PyNaCl==1.2.1 python-dateutil==2.6.1 #python-gssapi==0.6.4 pytz==2018.3 -PyYAML==5.1 +PyYAML==5.2 redis==3.5.3 requests==2.25.1 jms-storage==0.0.39 @@ -77,7 +77,7 @@ aliyun-python-sdk-core-v3==2.9.1 aliyun-python-sdk-ecs==4.10.1 rest_condition==1.0.3 python-ldap==3.3.1 -tencentcloud-sdk-python==3.0.40 +tencentcloud-sdk-python==3.0.477 django-radius==1.4.0 ipip-ipdb==1.2.1 django-redis-sessions==0.6.1 @@ -114,3 +114,8 @@ azure-identity==1.5.0 azure-mgmt-subscription==1.0.0 qingcloud-sdk==1.2.12 django-simple-history==3.0.0 +google-cloud-compute==0.5.0 +PyMySQL==1.0.2 +cx-Oracle==8.2.1 +psycopg2-binary==2.9.1 +alibabacloud_dysmsapi20170525==2.0.2