diff --git a/Dockerfile b/Dockerfile index 2174df9c0..581f40f18 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,9 +36,11 @@ ARG TOOLS=" \ curl \ default-libmysqlclient-dev \ default-mysql-client \ + iputils-ping \ locales \ nmap \ openssh-client \ + patch \ sshpass \ telnet \ vim \ diff --git a/README.md b/README.md index e83f8ee8f..c445740a2 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,6 @@
- JumpServer v3.0 正式发布。
-
9 年时间,倾情投入,用心做好一款开源堡垒机。
{% trans 'Username' %}: [{{ username }}]
+{% trans 'Assets' %}: [{{ asset }}]
+{% trans 'Account' %}: [{{ account }}]
+{% trans 'The user has just logged in to the asset. Please ensure that this is an authorized operation. If you suspect that this is an unauthorized access, please take appropriate measures immediately.' %}
+ +{% trans 'Thank you' %}!
+ diff --git a/apps/acls/templates/acls/user_login_reminder.html b/apps/acls/templates/acls/user_login_reminder.html new file mode 100644 index 000000000..3af4fd52a --- /dev/null +++ b/apps/acls/templates/acls/user_login_reminder.html @@ -0,0 +1,14 @@ +{% load i18n %} + +{% trans 'Username' %}: [{{ username }}]
+IP: [{{ ip }}]
+{% trans 'Login city' %}: [{{ city }}]
+{% trans 'User agent' %}: [{{ user_agent }}]
+{% trans 'The user has just successfully logged into the system. Please ensure that this is an authorized operation. If you suspect that this is an unauthorized access, please take appropriate measures immediately.' %}
+ +{% trans 'Thank you' %}!
+ diff --git a/apps/assets/api/asset/asset.py b/apps/assets/api/asset/asset.py index f1573629e..4b36b4bbe 100644 --- a/apps/assets/api/asset/asset.py +++ b/apps/assets/api/asset/asset.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- # +from collections import defaultdict + import django_filters from django.db.models import Q from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ +from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.status import HTTP_200_OK @@ -12,7 +15,7 @@ from accounts.tasks import push_accounts_to_assets_task, verify_accounts_connect from assets import serializers from assets.exceptions import NotSupportedTemporarilyError from assets.filters import IpInFilterBackend, LabelFilterBackend, NodeFilterBackend -from assets.models import Asset, Gateway, Platform +from assets.models import Asset, Gateway, Platform, Protocol from assets.tasks import test_assets_connectivity_manual, update_assets_hardware_info_manual from common.api import SuggestionMixin from common.drf.filters import BaseFilterSet, AttrRulesFilterBackend @@ -115,6 +118,7 @@ class AssetViewSet(SuggestionMixin, NodeFilterMixin, OrgBulkModelViewSet): ("gateways", "assets.view_gateway"), ("spec_info", "assets.view_asset"), ("gathered_info", "assets.view_asset"), + ("sync_platform_protocols", "assets.change_asset"), ) extra_filter_backends = [ LabelFilterBackend, IpInFilterBackend, @@ -152,6 +156,39 @@ class AssetViewSet(SuggestionMixin, NodeFilterMixin, OrgBulkModelViewSet): gateways = asset.domain.gateways return self.get_paginated_response_from_queryset(gateways) + @action(methods=['post'], detail=False, url_path='sync-platform-protocols') + def sync_platform_protocols(self, request, *args, **kwargs): + platform_id = request.data.get('platform_id') + platform = get_object_or_404(Platform, pk=platform_id) + assets = platform.assets.all() + + platform_protocols = { + p['name']: p['port'] + for p in platform.protocols.values('name', 'port') + } + asset_protocols_map = defaultdict(set) + protocols = assets.prefetch_related('protocols').values_list( + 'id', 'protocols__name' + ) + for asset_id, protocol in protocols: + asset_id = str(asset_id) + asset_protocols_map[asset_id].add(protocol) + objs = [] + for asset_id, protocols in asset_protocols_map.items(): + protocol_names = set(platform_protocols) - protocols + if not protocol_names: + continue + for name in protocol_names: + objs.append( + Protocol( + name=name, + port=platform_protocols[name], + asset_id=asset_id, + ) + ) + Protocol.objects.bulk_create(objs) + return Response(status=status.HTTP_200_OK) + def create(self, request, *args, **kwargs): if request.path.find('/api/v1/assets/assets/') > -1: error = _('Cannot create asset directly, you should create a host or other') diff --git a/apps/assets/automations/ping/custom/rdp/main.yml b/apps/assets/automations/ping/custom/rdp/main.yml index 75e40c027..a68670998 100644 --- a/apps/assets/automations/ping/custom/rdp/main.yml +++ b/apps/assets/automations/ping/custom/rdp/main.yml @@ -10,6 +10,6 @@ login_user: "{{ jms_account.username }}" login_password: "{{ jms_account.secret }}" login_host: "{{ jms_asset.address }}" - login_port: "{{ jms_asset.port }}" + login_port: "{{ jms_asset.protocols | selectattr('name', 'equalto', 'rdp') | map(attribute='port') | first }}" login_secret_type: "{{ jms_account.secret_type }}" login_private_key_path: "{{ jms_account.private_key_path }}" diff --git a/apps/assets/const/base.py b/apps/assets/const/base.py index 66c991caa..a77691c1b 100644 --- a/apps/assets/const/base.py +++ b/apps/assets/const/base.py @@ -1,9 +1,8 @@ +from django.conf import settings from django.db import models from django.db.models import TextChoices from django.utils.translation import gettext_lazy as _ -from jumpserver.utils import has_valid_xpack_license - class Type: def __init__(self, label, value): @@ -113,7 +112,7 @@ class BaseType(TextChoices): @classmethod def get_choices(cls): - if not has_valid_xpack_license(): + if not settings.XPACK_LICENSE_IS_VALID: return [ (tp.value, tp.label) for tp in cls.get_community_types() diff --git a/apps/assets/const/database.py b/apps/assets/const/database.py index 86e86ed40..261373688 100644 --- a/apps/assets/const/database.py +++ b/apps/assets/const/database.py @@ -7,6 +7,7 @@ class DatabaseTypes(BaseType): POSTGRESQL = 'postgresql', 'PostgreSQL' ORACLE = 'oracle', 'Oracle' SQLSERVER = 'sqlserver', 'SQLServer' + DB2 = 'db2', 'DB2' CLICKHOUSE = 'clickhouse', 'ClickHouse' MONGODB = 'mongodb', 'MongoDB' REDIS = 'redis', 'Redis' @@ -45,6 +46,15 @@ class DatabaseTypes(BaseType): 'change_secret_enabled': False, 'push_account_enabled': False, }, + cls.DB2: { + 'ansible_enabled': False, + 'ping_enabled': False, + 'gather_facts_enabled': False, + 'gather_accounts_enabled': False, + 'verify_account_enabled': False, + 'change_secret_enabled': False, + 'push_account_enabled': False, + }, cls.CLICKHOUSE: { 'ansible_enabled': False, 'ping_enabled': False, @@ -73,6 +83,7 @@ class DatabaseTypes(BaseType): cls.POSTGRESQL: [{'name': 'PostgreSQL'}], cls.ORACLE: [{'name': 'Oracle'}], cls.SQLSERVER: [{'name': 'SQLServer'}], + cls.DB2: [{'name': 'DB2'}], cls.CLICKHOUSE: [{'name': 'ClickHouse'}], cls.MONGODB: [{'name': 'MongoDB'}], cls.REDIS: [ diff --git a/apps/assets/const/protocol.py b/apps/assets/const/protocol.py index f1c593522..5aea2daec 100644 --- a/apps/assets/const/protocol.py +++ b/apps/assets/const/protocol.py @@ -22,6 +22,7 @@ class Protocol(ChoicesMixin, models.TextChoices): oracle = 'oracle', 'Oracle' postgresql = 'postgresql', 'PostgreSQL' sqlserver = 'sqlserver', 'SQLServer' + db2 = 'db2', 'DB2' clickhouse = 'clickhouse', 'ClickHouse' redis = 'redis', 'Redis' mongodb = 'mongodb', 'MongoDB' @@ -170,6 +171,12 @@ class Protocol(ChoicesMixin, models.TextChoices): } } }, + cls.db2: { + 'port': 5000, + 'required': True, + 'secret_types': ['password'], + 'xpack': True, + }, cls.clickhouse: { 'port': 9000, 'required': True, @@ -269,7 +276,7 @@ class Protocol(ChoicesMixin, models.TextChoices): } } } - if settings.XPACK_ENABLED: + if settings.XPACK_LICENSE_IS_VALID: choices = protocols[cls.chatgpt]['setting']['api_mode']['choices'] choices.extend([ ('gpt-4', 'GPT-4'), diff --git a/apps/assets/migrations/0100_auto_20220711_1413.py b/apps/assets/migrations/0100_auto_20220711_1413.py index 4e57be633..8bb3309e4 100644 --- a/apps/assets/migrations/0100_auto_20220711_1413.py +++ b/apps/assets/migrations/0100_auto_20220711_1413.py @@ -25,7 +25,7 @@ def migrate_asset_accounts(apps, schema_editor): count += len(auth_books) # auth book 和 account 相同的属性 same_attrs = [ - 'id', 'username', 'comment', 'date_created', 'date_updated', + 'username', 'comment', 'date_created', 'date_updated', 'created_by', 'asset_id', 'org_id', ] # 认证的属性,可能是 auth_book 的,可能是 system_user 的 diff --git a/apps/assets/migrations/0124_auto_20231007_1437.py b/apps/assets/migrations/0124_auto_20231007_1437.py new file mode 100644 index 000000000..930327439 --- /dev/null +++ b/apps/assets/migrations/0124_auto_20231007_1437.py @@ -0,0 +1,26 @@ +# Generated by Django 4.1.10 on 2023-10-07 06:37 + +from django.db import migrations + + +def add_db2_platform(apps, schema_editor): + platform_cls = apps.get_model('assets', 'Platform') + automation_cls = apps.get_model('assets', 'PlatformAutomation') + platform = platform_cls.objects.create( + name='DB2', internal=True, category='database', type='db2', + domain_enabled=True, su_enabled=False, comment='DB2', + created_by='System', updated_by='System', + ) + platform.protocols.create(name='db2', port=5000, primary=True, setting={}) + automation_cls.objects.create(ansible_enabled=False, platform=platform) + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0123_device_automation_ansible_enabled'), + ] + + operations = [ + migrations.RunPython(add_db2_platform) + ] diff --git a/apps/assets/migrations/0125_auto_20231011_1053.py b/apps/assets/migrations/0125_auto_20231011_1053.py new file mode 100644 index 000000000..11161c909 --- /dev/null +++ b/apps/assets/migrations/0125_auto_20231011_1053.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1.10 on 2023-10-11 02:53 + +from django.db import migrations + + +def change_windows_ping_method(apps, schema_editor): + platform_automation_cls = apps.get_model('assets', 'PlatformAutomation') + automations = platform_automation_cls.objects.filter(platform__name__in=['Windows', 'Windows2016']) + automations.update(ping_method='ping_by_rdp') + automations.update(verify_account_method='verify_account_by_rdp') + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0124_auto_20231007_1437'), + ] + + operations = [ + migrations.RunPython(change_windows_ping_method) + ] diff --git a/apps/assets/models/node.py b/apps/assets/models/node.py index 139295f69..d4fc8165d 100644 --- a/apps/assets/models/node.py +++ b/apps/assets/models/node.py @@ -402,12 +402,7 @@ class NodeAssetsMixin(NodeAllAssetsMappingMixin): return Asset.objects.filter(q).distinct() def get_assets_amount(self): - q = Q(node__key__startswith=f'{self.key}:') | Q(node__key=self.key) - return self.assets.through.objects.filter(q).count() - - def get_assets_account_by_children(self): - children = self.get_all_children().values_list() - return self.assets.through.objects.filter(node_id__in=children).count() + return self.get_all_assets().count() @classmethod def get_node_all_assets_by_key_v2(cls, key): diff --git a/apps/assets/serializers/asset/common.py b/apps/assets/serializers/asset/common.py index b88d22ce7..75d8c4c19 100644 --- a/apps/assets/serializers/asset/common.py +++ b/apps/assets/serializers/asset/common.py @@ -175,6 +175,8 @@ class AssetSerializer(BulkOrgResourceModelSerializer, WritableNestedModelSeriali protocols = self.initial_data.get('protocols') if protocols is not None: return + if getattr(self, 'instance', None): + return protocols_required, protocols_default = self._get_protocols_required_default() protocol_map = {str(protocol.id): protocol for protocol in protocols_required + protocols_default} @@ -281,14 +283,52 @@ class AssetSerializer(BulkOrgResourceModelSerializer, WritableNestedModelSeriali return protocols_data_map.values() @staticmethod - def accounts_create(accounts_data, asset): + def update_account_su_from(accounts, include_su_from_accounts): + if not include_su_from_accounts: + return + name_map = {account.name: account for account in accounts} + username_secret_type_map = { + (account.username, account.secret_type): account for account in accounts + } + + for name, username_secret_type in include_su_from_accounts.items(): + account = name_map.get(name) + if not account: + continue + su_from_account = username_secret_type_map.get(username_secret_type) + if su_from_account: + account.su_from = su_from_account + account.save() + + def accounts_create(self, accounts_data, asset): + from accounts.models import AccountTemplate if not accounts_data: return + + if not isinstance(accounts_data[0], dict): + raise serializers.ValidationError({'accounts': _("Invalid data")}) + + su_from_name_username_secret_type_map = {} for data in accounts_data: data['asset'] = asset.id + name = data.get('name') + su_from = data.pop('su_from', None) + template_id = data.get('template', None) + if template_id: + template = AccountTemplate.objects.get(id=template_id) + if template and template.su_from: + su_from_name_username_secret_type_map[template.name] = ( + template.su_from.username, template.su_from.secret_type + ) + elif isinstance(su_from, dict): + su_from = Account.objects.get(id=su_from.get('id')) + su_from_name_username_secret_type_map[name] = ( + su_from.username, su_from.secret_type + ) s = AssetAccountSerializer(data=accounts_data, many=True) s.is_valid(raise_exception=True) - s.save() + accounts = s.save() + self.update_account_su_from(accounts, su_from_name_username_secret_type_map) @atomic def create(self, validated_data): @@ -298,10 +338,37 @@ class AssetSerializer(BulkOrgResourceModelSerializer, WritableNestedModelSeriali self.perform_nodes_display_create(instance, nodes_display) return instance + @staticmethod + def sync_platform_protocols(instance, old_platform): + platform = instance.platform + + if str(old_platform.id) == str(instance.platform_id): + return + + platform_protocols = { + p['name']: p['port'] + for p in platform.protocols.values('name', 'port') + } + + protocols = set(instance.protocols.values_list('name', flat=True)) + protocol_names = set(platform_protocols) - protocols + objs = [] + for name in protocol_names: + objs.append( + Protocol( + name=name, + port=platform_protocols[name], + asset_id=instance.id, + ) + ) + Protocol.objects.bulk_create(objs) + @atomic def update(self, instance, validated_data): + old_platform = instance.platform nodes_display = validated_data.pop('nodes_display', '') instance = super().update(instance, validated_data) + self.sync_platform_protocols(instance, old_platform) self.perform_nodes_display_create(instance, nodes_display) return instance diff --git a/apps/assets/serializers/asset/database.py b/apps/assets/serializers/asset/database.py index 17a122fd6..ff3e7288a 100644 --- a/apps/assets/serializers/asset/database.py +++ b/apps/assets/serializers/asset/database.py @@ -1,8 +1,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from rest_framework.serializers import ValidationError -from assets.models import Database +from assets.models import Database, Platform from assets.serializers.gateway import GatewayWithAccountSecretSerializer from .common import AssetSerializer @@ -20,13 +19,44 @@ class DatabaseSerializer(AssetSerializer): ] fields = AssetSerializer.Meta.fields + extra_fields - def validate(self, attrs): - platform = attrs.get('platform') - db_type_required = ('mongodb', 'postgresql') - if platform and getattr(platform, 'type') in db_type_required \ - and not attrs.get('db_name'): - raise ValidationError({'db_name': _('This field is required.')}) - return attrs + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.set_db_name_required() + + def get_platform(self): + platform = None + platform_id = None + + if getattr(self, 'initial_data', None): + platform_id = self.initial_data.get('platform') + if isinstance(platform_id, dict): + platform_id = platform_id.get('id') or platform_id.get('pk') + if not platform_id and self.instance: + platform = self.instance.platform + elif getattr(self, 'instance', None): + if isinstance(self.instance, list): + return + platform = self.instance.platform + elif self.context.get('request'): + platform_id = self.context['request'].query_params.get('platform') + + if not platform and platform_id: + platform = Platform.objects.filter(id=platform_id).first() + return platform + + def set_db_name_required(self): + db_field = self.fields.get('db_name') + if not db_field: + return + + platform = self.get_platform() + if not platform: + return + + if platform.type in ['mysql', 'mariadb']: + db_field.required = False + db_field.allow_blank = True + db_field.allow_null = True class DatabaseWithGatewaySerializer(DatabaseSerializer): diff --git a/apps/assets/serializers/node.py b/apps/assets/serializers/node.py index ac161318b..70da291c3 100644 --- a/apps/assets/serializers/node.py +++ b/apps/assets/serializers/node.py @@ -30,8 +30,9 @@ class NodeSerializer(BulkOrgResourceModelSerializer): if '/' in data: error = _("Can't contains: " + "/") raise serializers.ValidationError(error) - if self.instance: - instance = self.instance + view = self.context['view'] + instance = self.instance or getattr(view, 'instance', None) + if instance: siblings = instance.get_siblings() else: instance = Node.org_root() diff --git a/apps/audits/api.py b/apps/audits/api.py index 72c1d8a99..35315aa65 100644 --- a/apps/audits/api.py +++ b/apps/audits/api.py @@ -6,7 +6,6 @@ from importlib import import_module from django.conf import settings from django.db.models import F, Value, CharField, Q from django.http import HttpResponse, FileResponse -from django.utils import timezone from django.utils.encoding import escape_uri_path from rest_framework import generics from rest_framework import status @@ -185,6 +184,8 @@ class ResourceActivityAPIView(generics.ListAPIView): 'r_user', 'r_action', 'r_type' ) org_q = Q(org_id=Organization.SYSTEM_ID) | Q(org_id=current_org.id) + if resource_id: + org_q |= Q(org_id='') | Q(org_id=Organization.ROOT_ID) with tmp_to_root_org(): qs1 = self.get_operate_log_qs(fields, limit, org_q, resource_id=resource_id) qs2 = self.get_activity_log_qs(fields, limit, org_q, resource_id=resource_id) @@ -216,11 +217,10 @@ class OperateLogViewSet(OrgReadonlyModelViewSet): return super().get_serializer_class() def get_queryset(self): - org_q = Q(org_id=current_org.id) + qs = OperateLog.objects.all() if self.is_action_detail: - org_q |= Q(org_id=Organization.SYSTEM_ID) - with tmp_to_root_org(): - qs = OperateLog.objects.filter(org_q) + with tmp_to_root_org(): + qs |= OperateLog.objects.filter(org_id=Organization.SYSTEM_ID) es_config = settings.OPERATE_LOG_ELASTICSEARCH_CONFIG if es_config: engine_mod = import_module(TYPE_ENGINE_MAPPING['es']) @@ -257,9 +257,8 @@ class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet): serializer_class = UserSessionSerializer filterset_fields = ['id', 'ip', 'city', 'type'] search_fields = ['id', 'ip', 'city'] - rbac_perms = { - 'offline': ['users.offline_usersession'] + 'offline': ['audits.offline_usersession'] } @property @@ -269,9 +268,7 @@ class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet): def get_queryset(self): keys = UserSession.get_keys() - queryset = UserSession.objects.filter( - date_expired__gt=timezone.now(), key__in=keys - ) + queryset = UserSession.objects.filter(key__in=keys) if current_org.is_root(): return queryset user_ids = self.org_user_ids @@ -281,7 +278,9 @@ class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet): @action(['POST'], detail=False, url_path='offline') def offline(self, request, *args, **kwargs): ids = request.data.get('ids', []) - queryset = self.get_queryset().exclude(key=request.session.session_key).filter(id__in=ids) + queryset = self.get_queryset() + session_key = request.session.session_key + queryset = queryset.exclude(key=session_key).filter(id__in=ids) if not queryset.exists(): return Response(status=status.HTTP_200_OK) diff --git a/apps/audits/backends/db.py b/apps/audits/backends/db.py index ac589931d..870c25c9c 100644 --- a/apps/audits/backends/db.py +++ b/apps/audits/backends/db.py @@ -58,7 +58,7 @@ class OperateLogStore(object): return diff_list def save(self, **kwargs): - log_id = kwargs.get('id', '') + log_id = kwargs.pop('id', None) before = kwargs.pop('before') or {} after = kwargs.pop('after') or {} diff --git a/apps/audits/const.py b/apps/audits/const.py index 44d3a556f..4418e9e7f 100644 --- a/apps/audits/const.py +++ b/apps/audits/const.py @@ -30,6 +30,13 @@ class ActionChoices(TextChoices): login = "login", _("Login") change_auth = "change_password", _("Change password") + accept = 'accept', _('Accept') + review = 'review', _('Review') + notice = 'notice', _('Notifications') + reject = 'reject', _('Reject') + approve = 'approve', _('Approve') + close = 'close', _('Close') + class LoginTypeChoices(TextChoices): web = "W", _("Web") diff --git a/apps/audits/migrations/0023_auto_20230906_1322.py b/apps/audits/migrations/0023_auto_20230906_1322.py index 98447b7b3..34998318c 100644 --- a/apps/audits/migrations/0023_auto_20230906_1322.py +++ b/apps/audits/migrations/0023_auto_20230906_1322.py @@ -1,7 +1,7 @@ # Generated by Django 4.1.10 on 2023-09-06 05:31 -from django.db import migrations, models import django.utils.timezone +from django.db import migrations, models class Migration(migrations.Migration): @@ -19,7 +19,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='operatelog', name='action', - field=models.CharField(choices=[('view', 'View'), ('update', 'Update'), ('delete', 'Delete'), ('create', 'Create'), ('download', 'Download'), ('connect', 'Connect'), ('login', 'Login'), ('change_password', 'Change password')], max_length=16, verbose_name='Action'), + field=models.CharField(choices=[('view', 'View'), ('update', 'Update'), ('delete', 'Delete'), ('create', 'Create'), ('download', 'Download'), ('connect', 'Connect'), ('login', 'Login'), ('change_password', 'Change password'), ('accept', 'Accept'), ('review', 'Review'), ('notice', 'Notifications'), ('reject', 'Reject'), ('approve', 'Approve'), ('close', 'Close')], max_length=16, verbose_name='Action'), ), migrations.AlterField( model_name='userloginlog', diff --git a/apps/audits/migrations/0025_remove_usersession_date_expired.py b/apps/audits/migrations/0025_remove_usersession_date_expired.py new file mode 100644 index 000000000..0b495f72d --- /dev/null +++ b/apps/audits/migrations/0025_remove_usersession_date_expired.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.10 on 2023-10-18 08:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('audits', '0024_usersession'), + ] + + operations = [ + migrations.RemoveField( + model_name='usersession', + name='date_expired', + ), + ] diff --git a/apps/audits/models.py b/apps/audits/models.py index 34ec301f2..f7a669114 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -1,5 +1,6 @@ import os import uuid +from datetime import timedelta from importlib import import_module from django.conf import settings @@ -10,7 +11,7 @@ from django.utils import timezone from django.utils.translation import gettext, gettext_lazy as _ from common.db.encoder import ModelJSONFieldEncoder -from common.utils import lazyproperty +from common.utils import lazyproperty, i18n_trans from ops.models import JobExecution from orgs.mixins.models import OrgModelMixin, Organization from orgs.utils import current_org @@ -155,6 +156,10 @@ class ActivityLog(OrgModelMixin): verbose_name = _("Activity log") ordering = ('-datetime',) + def __str__(self): + detail = i18n_trans(self.detail) + return "{} {}".format(detail, self.resource_id) + def save(self, *args, **kwargs): if current_org.is_root() and not self.org_id: self.org_id = Organization.ROOT_ID @@ -259,7 +264,6 @@ class UserSession(models.Model): type = models.CharField(choices=LoginTypeChoices.choices, max_length=2, verbose_name=_("Login type")) backend = models.CharField(max_length=32, default="", verbose_name=_("Authentication backend")) date_created = models.DateTimeField(null=True, blank=True, verbose_name=_('Date created')) - date_expired = models.DateTimeField(null=True, blank=True, verbose_name=_("Date expired"), db_index=True) user = models.ForeignKey( 'users.User', verbose_name=_('User'), related_name='sessions', on_delete=models.CASCADE ) @@ -271,6 +275,14 @@ class UserSession(models.Model): def backend_display(self): return gettext(self.backend) + @property + def date_expired(self): + session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore + session_store = session_store_cls(session_key=self.key) + cache_key = session_store.cache_key + ttl = caches[settings.SESSION_CACHE_ALIAS].ttl(cache_key) + return timezone.now() + timedelta(seconds=ttl) + @staticmethod def get_keys(): session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore @@ -280,8 +292,8 @@ class UserSession(models.Model): @classmethod def clear_expired_sessions(cls): - cls.objects.filter(date_expired__lt=timezone.now()).delete() - cls.objects.exclude(key__in=cls.get_keys()).delete() + keys = cls.get_keys() + cls.objects.exclude(key__in=keys).delete() class Meta: ordering = ['-date_created'] diff --git a/apps/audits/serializers.py b/apps/audits/serializers.py index 829986297..472f6ae28 100644 --- a/apps/audits/serializers.py +++ b/apps/audits/serializers.py @@ -169,6 +169,7 @@ class FileSerializer(serializers.Serializer): class UserSessionSerializer(serializers.ModelSerializer): type = LabeledChoiceField(choices=LoginTypeChoices.choices, label=_("Type")) user = ObjectRelatedField(required=False, queryset=User.objects, label=_('User')) + date_expired = serializers.DateTimeField(format="%Y/%m/%d %H:%M:%S", label=_('Date expired')) is_current_user_session = serializers.SerializerMethodField() class Meta: diff --git a/apps/audits/signal_handlers/activity_log.py b/apps/audits/signal_handlers/activity_log.py index 4b41d2d1d..4f564a3c4 100644 --- a/apps/audits/signal_handlers/activity_log.py +++ b/apps/audits/signal_handlers/activity_log.py @@ -70,6 +70,8 @@ class ActivityLogHandler: def create_activities(resource_ids, detail, detail_id, action, org_id): if not resource_ids: return + if not org_id: + org_id = Organization.ROOT_ID activities = [ ActivityLog( resource_id=getattr(resource_id, 'pk', resource_id), @@ -92,6 +94,8 @@ def after_task_publish_for_activity_log(headers=None, body=None, **kwargs): logger.error(f'Get celery task info error: {e}', exc_info=True) else: logger.debug(f'Create activity log for celery task: {task_id}') + if not resource_ids: + return create_activities(resource_ids, detail, task_id, action=ActivityChoices.task, org_id=org_id) @@ -110,6 +114,8 @@ def on_session_or_login_log_created(sender, instance=None, created=False, **kwar logger.error('Activity log handler not found: {}'.format(sender)) resource_ids, detail, act_type, org_id = func(instance) + if not resource_ids: + return return create_activities(resource_ids, detail, instance.id, act_type, org_id) diff --git a/apps/audits/signal_handlers/login_log.py b/apps/audits/signal_handlers/login_log.py index fae32a44b..2d8560412 100644 --- a/apps/audits/signal_handlers/login_log.py +++ b/apps/audits/signal_handlers/login_log.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # -from datetime import timedelta -from importlib import import_module from django.conf import settings from django.contrib.auth import BACKEND_SESSION_KEY @@ -11,6 +9,8 @@ from django.utils.functional import LazyObject from django.utils.translation import gettext_lazy as _ from rest_framework.request import Request +from acls.models import LoginACL +from acls.notifications import UserLoginReminderMsg from audits.models import UserLoginLog from authentication.signals import post_auth_failed, post_auth_success from authentication.utils import check_different_city_login_if_need @@ -82,10 +82,10 @@ def generate_data(username, request, login_type=None): def create_user_session(request, user_id, instance: UserLoginLog): + # TODO 目前只记录 web 登录的 session + if instance.type != LoginTypeChoices.web: + return session_key = request.session.session_key or '-' - session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore - session_store = session_store_cls(session_key=session_key) - ttl = session_store.get_expiry_age() online_session_data = { 'user_id': user_id, @@ -96,26 +96,45 @@ def create_user_session(request, user_id, instance: UserLoginLog): 'backend': instance.backend, 'user_agent': instance.user_agent, 'date_created': instance.datetime, - 'date_expired': instance.datetime + timedelta(seconds=ttl), } user_session = UserSession.objects.create(**online_session_data) - request.session['user_session_id'] = user_session.id + request.session['user_session_id'] = str(user_session.id) + + +def send_login_info_to_reviewers(instance: UserLoginLog | str, auth_acl_id): + if isinstance(instance, str): + instance = UserLoginLog.objects.filter(id=instance).first() + + if not instance: + return + + acl = LoginACL.objects.filter(id=auth_acl_id).first() + if not acl or not acl.reviewers.exists(): + return + + reviewers = acl.reviewers.all() + for reviewer in reviewers: + UserLoginReminderMsg(reviewer, instance).publish_async() @receiver(post_auth_success) def on_user_auth_success(sender, user, request, login_type=None, **kwargs): logger.debug('User login success: {}'.format(user.username)) check_different_city_login_if_need(user, request) - data = generate_data( - user.username, request, login_type=login_type - ) - request.session['login_time'] = data['datetime'].strftime("%Y-%m-%d %H:%M:%S") + data = generate_data(user.username, request, login_type=login_type) + request.session['login_time'] = data['datetime'].strftime('%Y-%m-%d %H:%M:%S') data.update({'mfa': int(user.mfa_enabled), 'status': True}) instance = write_login_log(**data) - # TODO 目前只记录 web 登录的 session - if instance.type != LoginTypeChoices.web: - return + create_user_session(request, user.id, instance) + request.session['user_log_id'] = str(instance.id) + request.session['can_send_notifications'] = True + auth_notice_required = request.session.get('auth_notice_required') + if not auth_notice_required: + return + + auth_acl_id = request.session.get('auth_acl_id') + send_login_info_to_reviewers(instance, auth_acl_id) @receiver(post_auth_failed) diff --git a/apps/authentication/api/access_key.py b/apps/authentication/api/access_key.py index 9253f449d..027fdf75f 100644 --- a/apps/authentication/api/access_key.py +++ b/apps/authentication/api/access_key.py @@ -1,20 +1,48 @@ # -*- coding: utf-8 -*- # -from rest_framework.viewsets import ModelViewSet +from django.utils.translation import gettext as _ +from rest_framework import serializers +from rest_framework.response import Response +from common.api import JMSModelViewSet from rbac.permissions import RBACPermission -from ..serializers import AccessKeySerializer +from ..const import ConfirmType +from ..permissions import UserConfirmation +from ..serializers import AccessKeySerializer, AccessKeyCreateSerializer -class AccessKeyViewSet(ModelViewSet): - serializer_class = AccessKeySerializer - search_fields = ['^id', '^secret'] +class AccessKeyViewSet(JMSModelViewSet): + serializer_classes = { + 'default': AccessKeySerializer, + 'create': AccessKeyCreateSerializer + } + search_fields = ['^id'] permission_classes = [RBACPermission] def get_queryset(self): return self.request.user.access_keys.all() + def get_permissions(self): + if self.is_swagger_request(): + return super().get_permissions() + + if self.action == 'create': + self.permission_classes = [ + RBACPermission, UserConfirmation.require(ConfirmType.PASSWORD) + ] + return super().get_permissions() + def perform_create(self, serializer): user = self.request.user - user.create_access_key() + if user.access_keys.count() >= 10: + raise serializers.ValidationError(_('Access keys can be created at most 10')) + key = user.create_access_key() + return key + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + key = self.perform_create(serializer) + serializer = self.get_serializer(instance=key) + return Response(serializer.data, status=201) diff --git a/apps/authentication/api/confirm.py b/apps/authentication/api/confirm.py index 3c0e670f0..2bb371e48 100644 --- a/apps/authentication/api/confirm.py +++ b/apps/authentication/api/confirm.py @@ -4,27 +4,37 @@ import time from django.utils.translation import gettext_lazy as _ from rest_framework import status -from rest_framework.generics import RetrieveAPIView, CreateAPIView +from rest_framework.decorators import action +from rest_framework.generics import RetrieveAPIView from rest_framework.response import Response -from common.permissions import IsValidUser, UserConfirmation +from authentication.permissions import UserConfirmation +from common.api import JMSGenericViewSet +from common.permissions import IsValidUser from ..const import ConfirmType from ..serializers import ConfirmSerializer class ConfirmBindORUNBindOAuth(RetrieveAPIView): - permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),) + permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.RELOGIN),) def retrieve(self, request, *args, **kwargs): return Response('ok') -class ConfirmApi(RetrieveAPIView, CreateAPIView): +class UserConfirmationViewSet(JMSGenericViewSet): permission_classes = (IsValidUser,) serializer_class = ConfirmSerializer + @action(methods=['get'], detail=False) + def check(self, request): + confirm_type = request.query_params.get('confirm_type', 'password') + permission = UserConfirmation.require(confirm_type)() + permission.has_permission(request, self) + return Response('ok') + def get_confirm_backend(self, confirm_type): - backend_classes = ConfirmType.get_can_confirm_backend_classes(confirm_type) + backend_classes = ConfirmType.get_prop_backends(confirm_type) if not backend_classes: return for backend_cls in backend_classes: @@ -33,12 +43,12 @@ class ConfirmApi(RetrieveAPIView, CreateAPIView): continue return backend - def retrieve(self, request, *args, **kwargs): - confirm_type = request.query_params.get('confirm_type') + def list(self, request, *args, **kwargs): + confirm_type = request.query_params.get('confirm_type', 'password') backend = self.get_confirm_backend(confirm_type) if backend is None: msg = _('This action require verify your MFA') - return Response(data={'error': msg}, status=status.HTTP_404_NOT_FOUND) + return Response(data={'error': msg}, status=status.HTTP_400_BAD_REQUEST) data = { 'confirm_type': backend.name, @@ -51,7 +61,7 @@ class ConfirmApi(RetrieveAPIView, CreateAPIView): serializer.is_valid(raise_exception=True) validated_data = serializer.validated_data - confirm_type = validated_data.get('confirm_type') + confirm_type = validated_data.get('confirm_type', 'password') mfa_type = validated_data.get('mfa_type') secret_key = validated_data.get('secret_key') diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index 955ca4028..e41b47f76 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -15,12 +15,14 @@ from rest_framework.request import Request from rest_framework.response import Response from accounts.const import AliasAccount +from acls.notifications import AssetLoginReminderMsg from common.api import JMSModelViewSet from common.exceptions import JMSException -from common.utils import random_string, get_logger, get_request_ip +from common.utils import random_string, get_logger, get_request_ip_or_data from common.utils.django import get_request_os from common.utils.http import is_true, is_false from orgs.mixins.api import RootOrgViewMixin +from orgs.utils import tmp_to_org from perms.models import ActionChoices from terminal.connect_methods import NativeClient, ConnectMethodUtil from terminal.models import EndpointRule, Endpoint @@ -298,6 +300,7 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView 'get_rdp_file': 'authentication.add_connectiontoken', 'get_client_protocol_url': 'authentication.add_connectiontoken', } + input_username = '' def get_queryset(self): queryset = ConnectionToken.objects \ @@ -313,21 +316,42 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView return super().perform_create(serializer) def _insert_connect_options(self, data, user): - name = 'file_name_conflict_resolution' connect_options = data.pop('connect_options', {}) - preference = Preference.objects.filter( - name=name, user=user, category='koko' - ).first() - value = preference.value if preference else FileNameConflictResolution.REPLACE - connect_options[name] = value + default_name_opts = { + 'file_name_conflict_resolution': FileNameConflictResolution.REPLACE, + 'terminal_theme_name': 'Default', + } + preferences_query = Preference.objects.filter( + user=user, category='koko', name__in=default_name_opts.keys() + ).values_list('name', 'value') + preferences = dict(preferences_query) + for name in default_name_opts.keys(): + value = preferences.get(name, default_name_opts[name]) + connect_options[name] = value data['connect_options'] = connect_options + @staticmethod + def get_input_username(data): + input_username = data.get('input_username', '') + if input_username: + return input_username + + account = data.get('account', '') + if account == '@USER': + input_username = str(data.get('user', '')) + elif account == '@INPUT': + input_username = '@INPUT' + else: + input_username = account + return input_username + def validate_serializer(self, serializer): data = serializer.validated_data user = self.get_user(serializer) self._insert_connect_options(data, user) asset = data.get('asset') account_name = data.get('account') + self.input_username = self.get_input_username(data) _data = self._validate(user, asset, account_name) data.update(_data) return serializer @@ -374,28 +398,62 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView raise JMSException(code='perm_expired', detail=msg) return account + def _record_operate_log(self, acl, asset): + from audits.handler import create_or_update_operate_log + with tmp_to_org(asset.org_id): + after = { + str(_('Assets')): str(asset), + str(_('Account')): self.input_username + } + object_name = acl._meta.object_name + resource_type = acl._meta.verbose_name + create_or_update_operate_log( + acl.action, resource_type, resource=acl, + after=after, object_name=object_name + ) + def _validate_acl(self, user, asset, account): from acls.models import LoginAssetACL acls = LoginAssetACL.filter_queryset(user=user, asset=asset, account=account) - ip = get_request_ip(self.request) + ip = get_request_ip_or_data(self.request) acl = LoginAssetACL.get_match_rule_acls(user, ip, acls) if not acl: return if acl.is_action(acl.ActionChoices.accept): + self._record_operate_log(acl, asset) return if acl.is_action(acl.ActionChoices.reject): + self._record_operate_log(acl, asset) msg = _('ACL action is reject: {}({})'.format(acl.name, acl.id)) raise JMSException(code='acl_reject', detail=msg) if acl.is_action(acl.ActionChoices.review): if not self.request.query_params.get('create_ticket'): msg = _('ACL action is review') raise JMSException(code='acl_review', detail=msg) - + self._record_operate_log(acl, asset) ticket = LoginAssetACL.create_login_asset_review_ticket( - user=user, asset=asset, account_username=account.username, + user=user, asset=asset, account_username=self.input_username, assignees=acl.reviewers.all(), org_id=asset.org_id ) return ticket + if acl.is_action(acl.ActionChoices.notice): + reviewers = acl.reviewers.all() + if not reviewers: + return + + self._record_operate_log(acl, asset) + for reviewer in reviewers: + AssetLoginReminderMsg( + reviewer, asset, user, self.input_username + ).publish_async() + + def create(self, request, *args, **kwargs): + try: + response = super().create(request, *args, **kwargs) + except JMSException as e: + data = {'code': e.detail.code, 'detail': e.detail} + return Response(data, status=e.status_code) + return response class SuperConnectionTokenViewSet(ConnectionTokenViewSet): diff --git a/apps/authentication/api/dingtalk.py b/apps/authentication/api/dingtalk.py index c8d9f7ce0..ad3bd26b1 100644 --- a/apps/authentication/api/dingtalk.py +++ b/apps/authentication/api/dingtalk.py @@ -4,8 +4,9 @@ from rest_framework.views import APIView from authentication import errors from authentication.const import ConfirmType +from authentication.permissions import UserConfirmation from common.api import RoleUserMixin, RoleAdminMixin -from common.permissions import UserConfirmation, IsValidUser +from common.permissions import IsValidUser from common.utils import get_logger from users.models import User @@ -27,7 +28,7 @@ class DingTalkQRUnBindBase(APIView): class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase): - permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),) + permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.RELOGIN),) class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase): diff --git a/apps/authentication/api/feishu.py b/apps/authentication/api/feishu.py index 148b99e51..cf95bb6ea 100644 --- a/apps/authentication/api/feishu.py +++ b/apps/authentication/api/feishu.py @@ -4,12 +4,13 @@ from rest_framework.views import APIView from authentication import errors from authentication.const import ConfirmType +from authentication.permissions import UserConfirmation from common.api import RoleUserMixin, RoleAdminMixin -from common.permissions import UserConfirmation, IsValidUser +from common.permissions import IsValidUser from common.utils import get_logger from users.models import User -logger = get_logger(__file__) +logger = get_logger(__name__) class FeiShuQRUnBindBase(APIView): @@ -27,7 +28,7 @@ class FeiShuQRUnBindBase(APIView): class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase): - permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),) + permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.RELOGIN),) class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase): diff --git a/apps/authentication/api/password.py b/apps/authentication/api/password.py index 86801bc6c..cc1e6aff7 100644 --- a/apps/authentication/api/password.py +++ b/apps/authentication/api/password.py @@ -1,3 +1,5 @@ +import time + from django.core.cache import cache from django.http import HttpResponseRedirect from django.shortcuts import reverse @@ -7,7 +9,7 @@ from rest_framework.generics import CreateAPIView from rest_framework.permissions import AllowAny from rest_framework.response import Response -from authentication.errors import PasswordInvalid +from authentication.errors import PasswordInvalid, IntervalTooShort from authentication.mixins import AuthMixin from authentication.mixins import authenticate from authentication.serializers import ( @@ -38,18 +40,18 @@ class UserResetPasswordSendCodeApi(CreateAPIView): return None, err_msg return user, None - def create(self, request, *args, **kwargs): - token = request.GET.get('token') - userinfo = cache.get(token) - if not userinfo: - return HttpResponseRedirect(reverse('authentication:forgot-previewing')) + @staticmethod + def safe_send_code(token, code, target, form_type, content): + token_sent_key = '{}_send_at'.format(token) + token_send_at = cache.get(token_sent_key, 0) + if token_send_at: + raise IntervalTooShort(60) + SendAndVerifyCodeUtil(target, code, backend=form_type, **content).gen_and_send_async() + cache.set(token_sent_key, int(time.time()), 60) - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = userinfo.get('username') + def prepare_code_data(self, user_info, serializer): + username = user_info.get('username') form_type = serializer.validated_data['form_type'] - code = random_string(6, lower=False, upper=False) - other_args = {} target = serializer.validated_data[form_type] if form_type == 'sms': @@ -59,15 +61,30 @@ class UserResetPasswordSendCodeApi(CreateAPIView): query_key = form_type user, err = self.is_valid_user(username=username, **{query_key: target}) if not user: - return Response({'error': err}, status=400) + raise ValueError(err) + code = random_string(6, lower=False, upper=False) subject = '%s: %s' % (get_login_title(), _('Forgot password')) context = { 'user': user, 'title': subject, 'code': code, } message = render_to_string('authentication/_msg_reset_password_code.html', context) - other_args['subject'], other_args['message'] = subject, message - SendAndVerifyCodeUtil(target, code, backend=form_type, **other_args).gen_and_send_async() + content = {'subject': subject, 'message': message} + return code, target, form_type, content + + def create(self, request, *args, **kwargs): + token = request.GET.get('token') + user_info = cache.get(token) + if not user_info: + return HttpResponseRedirect(reverse('authentication:forgot-previewing')) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + code, target, form_type, content = self.prepare_code_data(user_info, serializer) + except ValueError as e: + return Response({'error': str(e)}, status=400) + self.safe_send_code(token, code, target, form_type, content) return Response({'data': 'ok'}, status=200) diff --git a/apps/authentication/api/wecom.py b/apps/authentication/api/wecom.py index e704d3d4b..6dcfe539c 100644 --- a/apps/authentication/api/wecom.py +++ b/apps/authentication/api/wecom.py @@ -4,8 +4,9 @@ from rest_framework.views import APIView from authentication import errors from authentication.const import ConfirmType +from authentication.permissions import UserConfirmation from common.api import RoleUserMixin, RoleAdminMixin -from common.permissions import UserConfirmation, IsValidUser +from common.permissions import IsValidUser from common.utils import get_logger from users.models import User @@ -27,7 +28,7 @@ class WeComQRUnBindBase(APIView): class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase): - permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),) + permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.RELOGIN),) class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase): diff --git a/apps/authentication/backends/drf.py b/apps/authentication/backends/drf.py index 8d7d60e99..4ba879cc2 100644 --- a/apps/authentication/backends/drf.py +++ b/apps/authentication/backends/drf.py @@ -1,119 +1,33 @@ # -*- coding: utf-8 -*- # -import time -import uuid - from django.contrib.auth import get_user_model from django.core.cache import cache +from django.utils import timezone from django.utils.translation import gettext as _ -from rest_framework import HTTP_HEADER_ENCODING from rest_framework import authentication, exceptions -from six import text_type from common.auth import signature -from common.utils import get_object_or_none, make_signature, http_to_unixtime -from .base import JMSBaseAuthBackend +from common.utils import get_object_or_none from ..models import AccessKey, PrivateToken -UserModel = get_user_model() + +def date_more_than(d, seconds): + return d is None or (timezone.now() - d).seconds > seconds -def get_request_date_header(request): - date = request.META.get('HTTP_DATE', b'') - if isinstance(date, text_type): - # Work around django test client oddness - date = date.encode(HTTP_HEADER_ENCODING) - return date +def after_authenticate_update_date(user, token=None): + if date_more_than(user.date_api_key_last_used, 60): + user.date_api_key_last_used = timezone.now() + user.save(update_fields=['date_api_key_last_used']) - -class AccessKeyAuthentication(authentication.BaseAuthentication): - """App使用Access key进行签名认证, 目前签名算法比较简单, - app注册或者手动建立后,会生成 access_key_id 和 access_key_secret, - 然后使用 如下算法生成签名: - Signature = md5(access_key_secret + '\n' + Date) - example: Signature = md5('d32d2b8b-9a10-4b8d-85bb-1a66976f6fdc' + '\n' + - 'Thu, 12 Jan 2017 08:19:41 GMT') - 请求时设置请求header - header['Authorization'] = 'Sign access_key_id:Signature' 如: - header['Authorization'] = - 'Sign d32d2b8b-9a10-4b8d-85bb-1a66976f6fdc:OKOlmdxgYPZ9+SddnUUDbQ==' - - 验证时根据相同算法进行验证, 取到access_key_id对应的access_key_id, 从request - headers取到Date, 然后进行md5, 判断得到的结果是否相同, 如果是认证通过, 否则 认证 - 失败 - """ - keyword = 'Sign' - - def authenticate(self, request): - auth = authentication.get_authorization_header(request).split() - if not auth or auth[0].lower() != self.keyword.lower().encode(): - return None - - if len(auth) == 1: - msg = _('Invalid signature header. No credentials provided.') - raise exceptions.AuthenticationFailed(msg) - elif len(auth) > 2: - msg = _('Invalid signature header. Signature ' - 'string should not contain spaces.') - raise exceptions.AuthenticationFailed(msg) - - try: - sign = auth[1].decode().split(':') - if len(sign) != 2: - msg = _('Invalid signature header. ' - 'Format like AccessKeyId:Signature') - raise exceptions.AuthenticationFailed(msg) - except UnicodeError: - msg = _('Invalid signature header. ' - 'Signature string should not contain invalid characters.') - raise exceptions.AuthenticationFailed(msg) - - access_key_id = sign[0] - try: - uuid.UUID(access_key_id) - except ValueError: - raise exceptions.AuthenticationFailed('Access key id invalid') - request_signature = sign[1] - - return self.authenticate_credentials( - request, access_key_id, request_signature - ) - - @staticmethod - def authenticate_credentials(request, access_key_id, request_signature): - access_key = get_object_or_none(AccessKey, id=access_key_id) - request_date = get_request_date_header(request) - if access_key is None or not access_key.user: - raise exceptions.AuthenticationFailed(_('Invalid signature.')) - access_key_secret = access_key.secret - - try: - request_unix_time = http_to_unixtime(request_date) - except ValueError: - raise exceptions.AuthenticationFailed( - _('HTTP header: Date not provide ' - 'or not %a, %d %b %Y %H:%M:%S GMT')) - - if int(time.time()) - request_unix_time > 15 * 60: - raise exceptions.AuthenticationFailed( - _('Expired, more than 15 minutes')) - - signature = make_signature(access_key_secret, request_date) - if not signature == request_signature: - raise exceptions.AuthenticationFailed(_('Invalid signature.')) - - if not access_key.user.is_active: - raise exceptions.AuthenticationFailed(_('User disabled.')) - return access_key.user, None - - def authenticate_header(self, request): - return 'Sign access_key_id:Signature' + if token and hasattr(token, 'date_last_used') and date_more_than(token.date_last_used, 60): + token.date_last_used = timezone.now() + token.save(update_fields=['date_last_used']) class AccessTokenAuthentication(authentication.BaseAuthentication): keyword = 'Bearer' - # expiration = settings.TOKEN_EXPIRATION or 3600 model = get_user_model() def authenticate(self, request): @@ -125,19 +39,20 @@ class AccessTokenAuthentication(authentication.BaseAuthentication): msg = _('Invalid token header. No credentials provided.') raise exceptions.AuthenticationFailed(msg) elif len(auth) > 2: - msg = _('Invalid token header. Sign string ' - 'should not contain spaces.') + msg = _('Invalid token header. Sign string should not contain spaces.') raise exceptions.AuthenticationFailed(msg) try: token = auth[1].decode() except UnicodeError: - msg = _('Invalid token header. Sign string ' - 'should not contain invalid characters.') + msg = _('Invalid token header. Sign string should not contain invalid characters.') raise exceptions.AuthenticationFailed(msg) - return self.authenticate_credentials(token) + user, header = self.authenticate_credentials(token) + after_authenticate_update_date(user) + return user, header - def authenticate_credentials(self, token): + @staticmethod + def authenticate_credentials(token): model = get_user_model() user_id = cache.get(token) user = get_object_or_none(model, id=user_id) @@ -151,15 +66,23 @@ class AccessTokenAuthentication(authentication.BaseAuthentication): return self.keyword -class PrivateTokenAuthentication(JMSBaseAuthBackend, authentication.TokenAuthentication): +class PrivateTokenAuthentication(authentication.TokenAuthentication): model = PrivateToken + def authenticate(self, request): + user_token = super().authenticate(request) + if not user_token: + return + user, token = user_token + after_authenticate_update_date(user, token) + return user, token + class SessionAuthentication(authentication.SessionAuthentication): def authenticate(self, request): """ Returns a `User` if the request session currently has a logged in user. - Otherwise returns `None`. + Otherwise, returns `None`. """ # Get the session-based user from the underlying HttpRequest object @@ -195,6 +118,7 @@ class SignatureAuthentication(signature.SignatureAuthentication): if not key.is_active: return None, None user, secret = key.user, str(key.secret) + after_authenticate_update_date(user, key) return user, secret except (AccessKey.DoesNotExist, exceptions.ValidationError): return None, None diff --git a/apps/authentication/backends/oidc/views.py b/apps/authentication/backends/oidc/views.py index c638aeef6..98bd2ef2a 100644 --- a/apps/authentication/backends/oidc/views.py +++ b/apps/authentication/backends/oidc/views.py @@ -166,7 +166,7 @@ class OIDCAuthCallbackView(View): code_verifier = request.session.get('oidc_auth_code_verifier', None) logger.debug(log_prompt.format('Process authenticate')) user = auth.authenticate(nonce=nonce, request=request, code_verifier=code_verifier) - if user and user.is_valid: + if user: logger.debug(log_prompt.format('Login: {}'.format(user))) auth.login(self.request, user) # Stores an expiration timestamp in the user's session. This value will be used if diff --git a/apps/authentication/backends/passkey/api.py b/apps/authentication/backends/passkey/api.py index 8f5414122..ace882a92 100644 --- a/apps/authentication/backends/passkey/api.py +++ b/apps/authentication/backends/passkey/api.py @@ -4,24 +4,37 @@ from django.shortcuts import render from django.utils.translation import gettext as _ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated, AllowAny -from rest_framework.viewsets import ModelViewSet from authentication.mixins import AuthMixin +from common.api import JMSModelViewSet from .fido import register_begin, register_complete, auth_begin, auth_complete from .models import Passkey from .serializer import PasskeySerializer +from ...const import ConfirmType +from ...permissions import UserConfirmation from ...views import FlashMessageMixin -class PasskeyViewSet(AuthMixin, FlashMessageMixin, ModelViewSet): +class PasskeyViewSet(AuthMixin, FlashMessageMixin, JMSModelViewSet): serializer_class = PasskeySerializer permission_classes = (IsAuthenticated,) + def get_permissions(self): + if self.is_swagger_request(): + return super().get_permissions() + if self.action == 'register': + self.permission_classes = [ + IsAuthenticated, UserConfirmation.require(ConfirmType.PASSWORD) + ] + return super().get_permissions() + def get_queryset(self): return Passkey.objects.filter(user=self.request.user) @action(methods=['get', 'post'], detail=False, url_path='register') def register(self, request): + if request.user.source != 'local': + return JsonResponse({'error': _('Only register passkey for local user')}, status=400) if request.method == 'GET': register_data, state = register_begin(request) return JsonResponse(dict(register_data)) diff --git a/apps/authentication/backends/passkey/backends.py b/apps/authentication/backends/passkey/backends.py index dc7e1349b..4be1687d2 100644 --- a/apps/authentication/backends/passkey/backends.py +++ b/apps/authentication/backends/passkey/backends.py @@ -7,3 +7,6 @@ class PasskeyAuthBackend(JMSModelBackend): @staticmethod def is_enabled(): return settings.AUTH_PASSKEY + + def user_can_authenticate(self, user): + return user.source == 'local' diff --git a/apps/authentication/backends/pubkey.py b/apps/authentication/backends/pubkey.py index 1494d6b2e..bb9f91072 100644 --- a/apps/authentication/backends/pubkey.py +++ b/apps/authentication/backends/pubkey.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- # -from django.contrib.auth import get_user_model from django.conf import settings +from django.contrib.auth import get_user_model +from common.permissions import ServiceAccountSignaturePermission from .base import JMSBaseAuthBackend UserModel = get_user_model() @@ -18,6 +19,10 @@ class PublicKeyAuthBackend(JMSBaseAuthBackend): def authenticate(self, request, username=None, public_key=None, **kwargs): if not public_key: return None + + permission = ServiceAccountSignaturePermission() + if not permission.has_permission(request, None): + return None if username is None: username = kwargs.get(UserModel.USERNAME_FIELD) try: @@ -26,7 +31,7 @@ class PublicKeyAuthBackend(JMSBaseAuthBackend): return None else: if user.check_public_key(public_key) and \ - self.user_can_authenticate(user): + self.user_can_authenticate(user): return user def get_user(self, user_id): diff --git a/apps/authentication/confirm/base.py b/apps/authentication/confirm/base.py index 63258abce..5926da3e1 100644 --- a/apps/authentication/confirm/base.py +++ b/apps/authentication/confirm/base.py @@ -2,7 +2,6 @@ import abc class BaseConfirm(abc.ABC): - def __init__(self, user, request): self.user = user self.request = request @@ -23,7 +22,7 @@ class BaseConfirm(abc.ABC): @property def content(self): - return '' + return [] @abc.abstractmethod def authenticate(self, secret_key, mfa_type) -> tuple: diff --git a/apps/authentication/confirm/password.py b/apps/authentication/confirm/password.py index dc49576a3..e497bc261 100644 --- a/apps/authentication/confirm/password.py +++ b/apps/authentication/confirm/password.py @@ -15,3 +15,14 @@ class ConfirmPassword(BaseConfirm): ok = authenticate(self.request, username=self.user.username, password=secret_key) msg = '' if ok else _('Authentication failed password incorrect') return ok, msg + + @property + def content(self): + return [ + { + 'name': 'password', + 'display_name': _('Password'), + 'disabled': False, + 'placeholder': _('Password'), + } + ] diff --git a/apps/authentication/const.py b/apps/authentication/const.py index d7e0690db..1e06a4d35 100644 --- a/apps/authentication/const.py +++ b/apps/authentication/const.py @@ -11,7 +11,7 @@ CONFIRM_BACKEND_MAP = {backend.name: backend for backend in CONFIRM_BACKENDS} class ConfirmType(TextChoices): - ReLogin = ConfirmReLogin.name, ConfirmReLogin.display_name + RELOGIN = ConfirmReLogin.name, ConfirmReLogin.display_name PASSWORD = ConfirmPassword.name, ConfirmPassword.display_name MFA = ConfirmMFA.name, ConfirmMFA.display_name @@ -23,10 +23,11 @@ class ConfirmType(TextChoices): return types @classmethod - def get_can_confirm_backend_classes(cls, confirm_type): + def get_prop_backends(cls, confirm_type): types = cls.get_can_confirm_types(confirm_type) backend_classes = [ - CONFIRM_BACKEND_MAP[tp] for tp in types if tp in CONFIRM_BACKEND_MAP + CONFIRM_BACKEND_MAP[tp] + for tp in types if tp in CONFIRM_BACKEND_MAP ] return backend_classes diff --git a/apps/authentication/errors/mfa.py b/apps/authentication/errors/mfa.py index 40b97df92..8a0844145 100644 --- a/apps/authentication/errors/mfa.py +++ b/apps/authentication/errors/mfa.py @@ -36,3 +36,11 @@ class FeiShuNotBound(JMSException): class PasswordInvalid(JMSException): default_code = 'passwd_invalid' default_detail = _('Your password is invalid') + + +class IntervalTooShort(JMSException): + default_code = 'interval_too_short' + default_detail = _('Please wait for %s seconds before retry') + + def __init__(self, interval, *args, **kwargs): + super().__init__(detail=self.default_detail % interval, *args, **kwargs) diff --git a/apps/authentication/mfa/custom.py b/apps/authentication/mfa/custom.py index 0819dcfaa..70ceaf34c 100644 --- a/apps/authentication/mfa/custom.py +++ b/apps/authentication/mfa/custom.py @@ -10,7 +10,7 @@ logger = get_logger(__file__) mfa_custom_method = None if settings.MFA_CUSTOM: - """ 保证自定义认证方法在服务运行时不能被更改,只在第一次调用时加载一次 """ + """ 保证自定义的方法在服务运行时不能被更改,只在第一次调用时加载一次 """ try: mfa_custom_method_path = 'data.mfa.main.check_code' mfa_custom_method = import_string(mfa_custom_method_path) diff --git a/apps/authentication/middleware.py b/apps/authentication/middleware.py index b4ee86ed4..10659a53a 100644 --- a/apps/authentication/middleware.py +++ b/apps/authentication/middleware.py @@ -8,6 +8,7 @@ from django.utils.deprecation import MiddlewareMixin from django.utils.translation import gettext as _ from apps.authentication import mixins +from audits.signal_handlers import send_login_info_to_reviewers from authentication.signals import post_auth_failed from common.utils import gen_key_pair from common.utils import get_request_ip @@ -92,12 +93,12 @@ class ThirdPartyLoginMiddleware(mixins.AuthMixin): 'title': _('Authentication failed'), 'message': _('Authentication failed (before login check failed): {}').format(e), 'interval': 10, - 'redirect_url': reverse('authentication:login'), + 'redirect_url': reverse('authentication:login') + '?admin=1', 'auto_redirect': True, } response = render(request, 'authentication/auth_fail_flash_message_standalone.html', context) else: - if not self.request.session['auth_confirm_required']: + if not self.request.session.get('auth_confirm_required'): return response guard_url = reverse('authentication:login-guard') args = request.META.get('QUERY_STRING', '') @@ -105,6 +106,12 @@ class ThirdPartyLoginMiddleware(mixins.AuthMixin): guard_url = "%s?%s" % (guard_url, args) response = redirect(guard_url) finally: + if request.session.get('can_send_notifications') and \ + self.request.session.get('auth_notice_required'): + request.session['can_send_notifications'] = False + user_log_id = self.request.session.get('user_log_id') + auth_acl_id = self.request.session.get('auth_acl_id') + send_login_info_to_reviewers(user_log_id, auth_acl_id) return response diff --git a/apps/authentication/migrations/0023_auto_20231010_1101.py b/apps/authentication/migrations/0023_auto_20231010_1101.py new file mode 100644 index 000000000..81920dfd7 --- /dev/null +++ b/apps/authentication/migrations/0023_auto_20231010_1101.py @@ -0,0 +1,57 @@ +# Generated by Django 4.1.10 on 2023-10-10 02:47 + +import uuid +import authentication.models.access_key +from django.db import migrations, models + + +def migrate_access_key_secret(apps, schema_editor): + access_key_model = apps.get_model('authentication', 'AccessKey') + db_alias = schema_editor.connection.alias + + batch_size = 100 + count = 0 + + while True: + access_keys = access_key_model.objects.using(db_alias).all()[count:count + batch_size] + if not access_keys: + break + + count += len(access_keys) + access_keys_updated = [] + for access_key in access_keys: + s = access_key.secret + if len(s) != 32 or not s.islower(): + continue + try: + access_key.secret = '%s-%s-%s-%s-%s' % (s[:8], s[8:12], s[12:16], s[16:20], s[20:]) + access_keys_updated.append(access_key) + except (ValueError, IndexError): + pass + access_key_model.objects.bulk_update(access_keys_updated, fields=['secret']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0022_passkey'), + ] + + operations = [ + migrations.AddField( + model_name='accesskey', + name='date_last_used', + field=models.DateTimeField(blank=True, null=True, verbose_name='Date last used'), + ), + migrations.AddField( + model_name='privatetoken', + name='date_last_used', + field=models.DateTimeField(blank=True, null=True, verbose_name='Date last used'), + ), + migrations.AlterField( + model_name='accesskey', + name='secret', + field=models.CharField(default=authentication.models.access_key.default_secret, max_length=36, verbose_name='AccessKeySecret'), + ), + migrations.RunPython(migrate_access_key_secret), + ] diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index b301af6c3..31cb1dc19 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -19,7 +19,7 @@ from django.utils.translation import gettext as _ from rest_framework.request import Request from acls.models import LoginACL -from common.utils import get_request_ip, get_logger, bulk_get, FlashMessageUtil +from common.utils import get_request_ip_or_data, get_request_ip, get_logger, bulk_get, FlashMessageUtil from users.models import User from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil from . import errors @@ -76,6 +76,12 @@ def authenticate(request=None, **credentials): if user is None: continue + if not user.is_valid: + temp_user = user + temp_user.backend = backend_path + request.error_message = _('User is invalid') + return temp_user + # 检查用户是否允许认证 if not backend.user_allow_authenticate(user): temp_user = user @@ -101,13 +107,12 @@ auth.authenticate = authenticate class CommonMixin: request: Request + _ip = '' 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 + if not self._ip: + self._ip = get_request_ip_or_data(self.request) + return self._ip def raise_credential_error(self, error): raise self.partial_credential_error(error=error) @@ -355,6 +360,11 @@ class AuthACLMixin: self.request.session['auth_acl_id'] = str(acl.id) return + if acl.is_action(acl.ActionChoices.notice): + self.request.session['auth_notice_required'] = '1' + self.request.session['auth_acl_id'] = str(acl.id) + return + def _check_third_party_login_acl(self): request = self.request error_message = getattr(request, 'error_message', None) @@ -513,7 +523,8 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost def clear_auth_mark(self): keys = [ 'auth_password', 'user_id', 'auth_confirm_required', - 'auth_ticket_id', 'auth_acl_id' + 'auth_notice_required', 'auth_ticket_id', 'auth_acl_id', + 'user_session_id', 'user_log_id', 'can_send_notifications' ] for k in keys: self.request.session.pop(k, '') diff --git a/apps/authentication/models/access_key.py b/apps/authentication/models/access_key.py index 77fa67c74..5d9571569 100644 --- a/apps/authentication/models/access_key.py +++ b/apps/authentication/models/access_key.py @@ -5,16 +5,20 @@ from django.db import models from django.utils.translation import gettext_lazy as _ import common.db.models +from common.utils.random import random_string + + +def default_secret(): + return random_string(36) class AccessKey(models.Model): - id = models.UUIDField(verbose_name='AccessKeyID', primary_key=True, - default=uuid.uuid4, editable=False) - secret = models.UUIDField(verbose_name='AccessKeySecret', - default=uuid.uuid4, editable=False) + id = models.UUIDField(verbose_name='AccessKeyID', primary_key=True, default=uuid.uuid4, editable=False) + secret = models.CharField(verbose_name='AccessKeySecret', default=default_secret, max_length=36) user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name='User', on_delete=common.db.models.CASCADE_SIGNAL_SKIP, related_name='access_keys') is_active = models.BooleanField(default=True, verbose_name=_('Active')) + date_last_used = models.DateTimeField(null=True, blank=True, verbose_name=_('Date last used')) date_created = models.DateTimeField(auto_now_add=True) def get_id(self): diff --git a/apps/authentication/models/private_token.py b/apps/authentication/models/private_token.py index bb5f1da87..56669f018 100644 --- a/apps/authentication/models/private_token.py +++ b/apps/authentication/models/private_token.py @@ -1,9 +1,11 @@ +from django.db import models from django.utils.translation import gettext_lazy as _ from rest_framework.authtoken.models import Token class PrivateToken(Token): """Inherit from auth token, otherwise migration is boring""" + date_last_used = models.DateTimeField(null=True, blank=True, verbose_name=_('Date last used')) class Meta: verbose_name = _('Private Token') diff --git a/apps/authentication/permissions.py b/apps/authentication/permissions.py new file mode 100644 index 000000000..e49f72796 --- /dev/null +++ b/apps/authentication/permissions.py @@ -0,0 +1,58 @@ +import time + +from django.conf import settings +from rest_framework import permissions + +from authentication.const import ConfirmType +from authentication.models import ConnectionToken +from common.exceptions import UserConfirmRequired +from common.permissions import IsValidUser +from common.utils import get_object_or_none +from orgs.utils import tmp_to_root_org + + +class UserConfirmation(permissions.BasePermission): + ttl = 60 * 5 + min_level = 1 + confirm_type = 'relogin' + + def has_permission(self, request, view): + if not settings.SECURITY_VIEW_AUTH_NEED_MFA: + return True + + confirm_level = request.session.get('CONFIRM_LEVEL') + confirm_time = request.session.get('CONFIRM_TIME') + ttl = self.get_ttl() + if not confirm_level or not confirm_time or \ + confirm_level < self.min_level or \ + confirm_time < time.time() - ttl: + raise UserConfirmRequired(code=self.confirm_type) + return True + + def get_ttl(self): + if self.confirm_type == ConfirmType.MFA: + ttl = settings.SECURITY_MFA_VERIFY_TTL + else: + ttl = self.ttl + return ttl + + @classmethod + def require(cls, confirm_type=ConfirmType.RELOGIN, ttl=60 * 5): + min_level = ConfirmType.values.index(confirm_type) + 1 + name = 'UserConfirmationLevel{}TTL{}'.format(min_level, ttl) + return type(name, (cls,), {'min_level': min_level, 'ttl': ttl, 'confirm_type': confirm_type}) + + +class IsValidUserOrConnectionToken(IsValidUser): + def has_permission(self, request, view): + return super().has_permission(request, view) \ + or self.is_valid_connection_token(request) + + @staticmethod + def is_valid_connection_token(request): + token_id = request.query_params.get('token') + if not token_id: + return False + with tmp_to_root_org(): + token = get_object_or_none(ConnectionToken, id=token_id) + return token and token.is_valid diff --git a/apps/authentication/serializers/confirm.py b/apps/authentication/serializers/confirm.py index 5d747c656..dc7c997a6 100644 --- a/apps/authentication/serializers/confirm.py +++ b/apps/authentication/serializers/confirm.py @@ -7,4 +7,4 @@ from ..const import ConfirmType, MFAType class ConfirmSerializer(serializers.Serializer): confirm_type = serializers.ChoiceField(required=True, allow_blank=True, choices=ConfirmType.choices) mfa_type = serializers.ChoiceField(required=False, allow_blank=True, choices=MFAType.choices) - secret_key = EncryptedField(allow_blank=True) + secret_key = EncryptedField(allow_blank=True, required=False) diff --git a/apps/authentication/serializers/token.py b/apps/authentication/serializers/token.py index d1e87c0c0..e4dab13a5 100644 --- a/apps/authentication/serializers/token.py +++ b/apps/authentication/serializers/token.py @@ -10,16 +10,22 @@ from users.serializers import UserProfileSerializer from ..models import AccessKey, TempToken __all__ = [ - 'AccessKeySerializer', 'BearerTokenSerializer', + 'AccessKeySerializer', 'BearerTokenSerializer', 'SSOTokenSerializer', 'TempTokenSerializer', + 'AccessKeyCreateSerializer' ] class AccessKeySerializer(serializers.ModelSerializer): class Meta: model = AccessKey - fields = ['id', 'secret', 'is_active', 'date_created'] - read_only_fields = ['id', 'secret', 'date_created'] + fields = ['id', 'is_active', 'date_created', 'date_last_used'] + read_only_fields = ['id', 'date_created', 'date_last_used'] + + +class AccessKeyCreateSerializer(AccessKeySerializer): + class Meta(AccessKeySerializer.Meta): + fields = AccessKeySerializer.Meta.fields + ['secret'] class BearerTokenSerializer(serializers.Serializer): @@ -37,7 +43,8 @@ class BearerTokenSerializer(serializers.Serializer): def get_keyword(obj): return 'Bearer' - def update_last_login(self, user): + @staticmethod + def update_last_login(user): user.last_login = timezone.now() user.save(update_fields=['last_login']) @@ -96,7 +103,7 @@ class TempTokenSerializer(serializers.ModelSerializer): username = request.user.username kwargs = { 'username': username, 'secret': secret, - 'date_expired': timezone.now() + timezone.timedelta(seconds=5*60), + 'date_expired': timezone.now() + timezone.timedelta(seconds=5 * 60), } token = TempToken(**kwargs) token.save() diff --git a/apps/authentication/tests/__init__.py b/apps/authentication/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/authentication/tests/access_key.py b/apps/authentication/tests/access_key.py new file mode 100644 index 000000000..d0ec2842c --- /dev/null +++ b/apps/authentication/tests/access_key.py @@ -0,0 +1,34 @@ +# Python 示例 +# pip install requests drf-httpsig +import datetime +import json + +import requests +from httpsig.requests_auth import HTTPSignatureAuth + + +def get_auth(KeyID, SecretID): + signature_headers = ['(request-target)', 'accept', 'date'] + auth = HTTPSignatureAuth(key_id=KeyID, secret=SecretID, algorithm='hmac-sha256', headers=signature_headers) + return auth + + +def get_user_info(jms_url, auth): + url = jms_url + '/api/v1/users/users/?limit=1' + gmt_form = '%a, %d %b %Y %H:%M:%S GMT' + headers = { + 'Accept': 'application/json', + 'X-JMS-ORG': '00000000-0000-0000-0000-000000000002', + 'Date': datetime.datetime.utcnow().strftime(gmt_form) + } + + response = requests.get(url, auth=auth, headers=headers) + print(json.loads(response.text)) + + +if __name__ == '__main__': + jms_url = 'http://localhost:8080' + KeyID = '0753098d-810c-45fb-b42c-b27077147933' + SecretID = 'a58d2530-d7ee-4390-a204-3492e44dde84' + auth = get_auth(KeyID, SecretID) + get_user_info(jms_url, auth) diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index 6d40f68e8..e7e561ffd 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -13,6 +13,7 @@ router.register('sso', api.SSOViewSet, 'sso') router.register('temp-tokens', api.TempTokenViewSet, 'temp-token') router.register('connection-token', api.ConnectionTokenViewSet, 'connection-token') router.register('super-connection-token', api.SuperConnectionTokenViewSet, 'super-connection-token') +router.register('confirm', api.UserConfirmationViewSet, 'confirm') urlpatterns = [ path('wecom/qr/unbind/', api.WeComQRUnBindForUserApi.as_view(), name='wecom-qr-unbind'), @@ -29,7 +30,6 @@ urlpatterns = [ name='feishu-event-subscription-callback'), path('auth/', api.TokenCreateApi.as_view(), name='user-auth'), - path('confirm/', api.ConfirmApi.as_view(), name='user-confirm'), path('confirm-oauth/', api.ConfirmBindORUNBindOAuth.as_view(), name='confirm-oauth'), path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'), path('mfa/verify/', api.MFAChallengeVerifyApi.as_view(), name='mfa-verify'), diff --git a/apps/authentication/utils.py b/apps/authentication/utils.py index b53afa423..9cbc95bf2 100644 --- a/apps/authentication/utils.py +++ b/apps/authentication/utils.py @@ -20,19 +20,22 @@ def check_different_city_login_if_need(user, request): return ip = get_request_ip(request) or '0.0.0.0' - if not (ip and validate_ip(ip)): - city = DEFAULT_CITY - else: - city = get_ip_city(ip) or DEFAULT_CITY - city_white = [_('LAN'), 'LAN'] is_private = ipaddress.ip_address(ip).is_private - if not is_private: - last_user_login = UserLoginLog.objects.exclude(city__in=city_white) \ - .filter(username=user.username, status=True).first() + if is_private: + return + last_user_login = UserLoginLog.objects.exclude( + city__in=city_white + ).filter(username=user.username, status=True).first() + if not last_user_login: + return - if last_user_login and last_user_login.city != city: - DifferentCityLoginMessage(user, ip, city).publish_async() + city = get_ip_city(ip) + last_city = get_ip_city(last_user_login.ip) + if city == last_city: + return + + DifferentCityLoginMessage(user, ip, city).publish_async() def build_absolute_uri(request, path=None): diff --git a/apps/authentication/views/dingtalk.py b/apps/authentication/views/dingtalk.py index 819f1d055..536ef4155 100644 --- a/apps/authentication/views/dingtalk.py +++ b/apps/authentication/views/dingtalk.py @@ -1,6 +1,7 @@ from urllib.parse import urlencode from django.conf import settings +from django.contrib.auth import logout as auth_logout from django.db.utils import IntegrityError from django.http.request import HttpRequest from django.http.response import HttpResponseRedirect @@ -13,7 +14,7 @@ from authentication import errors from authentication.const import ConfirmType from authentication.mixins import AuthMixin from authentication.notifications import OAuthBindMessage -from common.permissions import UserConfirmation +from authentication.permissions import UserConfirmation from common.sdk.im.dingtalk import URL, DingTalk from common.utils import get_logger from common.utils.common import get_request_ip @@ -99,7 +100,7 @@ class DingTalkOAuthMixin(DingTalkBaseMixin, View): class DingTalkQRBindView(DingTalkQRMixin, View): - permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.ReLogin)) + permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.RELOGIN)) def get(self, request: HttpRequest): user = request.user @@ -158,6 +159,7 @@ class DingTalkQRBindCallbackView(DingTalkQRMixin, View): ip = get_request_ip(request) OAuthBindMessage(user, ip, _('DingTalk'), user_id).publish_async() msg = _('Binding DingTalk successfully') + auth_logout(request) response = self.get_success_response(redirect_url, msg, msg) return response diff --git a/apps/authentication/views/feishu.py b/apps/authentication/views/feishu.py index 0f62ef75d..4ceacc21b 100644 --- a/apps/authentication/views/feishu.py +++ b/apps/authentication/views/feishu.py @@ -1,6 +1,7 @@ from urllib.parse import urlencode from django.conf import settings +from django.contrib.auth import logout as auth_logout from django.db.utils import IntegrityError from django.http.request import HttpRequest from django.http.response import HttpResponseRedirect @@ -11,7 +12,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from authentication.const import ConfirmType from authentication.notifications import OAuthBindMessage -from common.permissions import UserConfirmation +from authentication.permissions import UserConfirmation from common.sdk.im.feishu import URL, FeiShu from common.utils import get_logger from common.utils.common import get_request_ip @@ -69,7 +70,7 @@ class FeiShuQRMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashMe class FeiShuQRBindView(FeiShuQRMixin, View): - permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.ReLogin)) + permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.RELOGIN)) def get(self, request: HttpRequest): redirect_url = request.GET.get('redirect_url') @@ -121,6 +122,7 @@ class FeiShuQRBindCallbackView(FeiShuQRMixin, View): ip = get_request_ip(request) OAuthBindMessage(user, ip, _('FeiShu'), user_id).publish_async() msg = _('Binding FeiShu successfully') + auth_logout(request) response = self.get_success_response(redirect_url, msg, msg) return response diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index a222aa4d8..6514e14eb 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -310,12 +310,6 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView): age = self.request.session.get_expiry_age() self.request.session.set_expiry(age) - def get(self, request, *args, **kwargs): - response = super().get(request, *args, **kwargs) - if request.user.is_authenticated: - response.set_cookie('jms_username', request.user.username) - return response - def get_redirect_url(self, *args, **kwargs): try: user = self.get_user_from_session() diff --git a/apps/authentication/views/wecom.py b/apps/authentication/views/wecom.py index 238d0b3e6..6817c84a0 100644 --- a/apps/authentication/views/wecom.py +++ b/apps/authentication/views/wecom.py @@ -1,6 +1,7 @@ from urllib.parse import urlencode from django.conf import settings +from django.contrib.auth import logout as auth_logout from django.db.utils import IntegrityError from django.http.request import HttpRequest from django.http.response import HttpResponseRedirect @@ -13,7 +14,7 @@ from authentication import errors from authentication.const import ConfirmType from authentication.mixins import AuthMixin from authentication.notifications import OAuthBindMessage -from common.permissions import UserConfirmation +from authentication.permissions import UserConfirmation from common.sdk.im.wecom import URL from common.sdk.im.wecom import WeCom from common.utils import get_logger @@ -100,7 +101,7 @@ class WeComOAuthMixin(WeComBaseMixin, View): class WeComQRBindView(WeComQRMixin, View): - permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.ReLogin)) + permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.RELOGIN)) def get(self, request: HttpRequest): user = request.user @@ -158,6 +159,7 @@ class WeComQRBindCallbackView(WeComQRMixin, View): ip = get_request_ip(request) OAuthBindMessage(user, ip, _('WeCom'), wecom_userid).publish_async() msg = _('Binding WeCom successfully') + auth_logout(request) response = self.get_success_response(redirect_url, msg, msg) return response diff --git a/apps/common/api/mixin.py b/apps/common/api/mixin.py index 4795673d7..d93459104 100644 --- a/apps/common/api/mixin.py +++ b/apps/common/api/mixin.py @@ -13,7 +13,7 @@ from common.drf.filters import ( IDSpmFilterBackend, CustomFilterBackend, IDInFilterBackend, IDNotFilterBackend, NotOrRelFilterBackend ) -from common.utils import get_logger +from common.utils import get_logger, lazyproperty from .action import RenderToJsonMixin from .serializer import SerializerMixin @@ -150,9 +150,9 @@ class OrderingFielderFieldsMixin: ordering_fields = None extra_ordering_fields = [] - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.ordering_fields = self._get_ordering_fields() + @lazyproperty + def ordering_fields(self): + return self._get_ordering_fields() def _get_ordering_fields(self): if isinstance(self.__class__.ordering_fields, (list, tuple)): @@ -179,7 +179,10 @@ class OrderingFielderFieldsMixin: model = self.queryset.model else: queryset = self.get_queryset() - model = queryset.model + if isinstance(queryset, list): + model = None + else: + model = queryset.model if not model: return [] @@ -201,4 +204,6 @@ class CommonApiMixin( SerializerMixin, ExtraFilterFieldsMixin, OrderingFielderFieldsMixin, QuerySetMixin, RenderToJsonMixin, PaginatedResponseMixin ): - pass + def is_swagger_request(self): + return getattr(self, 'swagger_fake_view', False) or \ + getattr(self, 'raw_action', '') == 'metadata' diff --git a/apps/common/const/crontab.py b/apps/common/const/crontab.py index bd9809176..e4de195eb 100644 --- a/apps/common/const/crontab.py +++ b/apps/common/const/crontab.py @@ -1,5 +1,5 @@ -CRONTAB_AT_AM_TWO = '0 14 * * *' +CRONTAB_AT_AM_TWO = '0 2 * * *' CRONTAB_AT_AM_TEN = '0 10 * * *' -CRONTAB_AT_PM_TWO = '0 2 * * *' +CRONTAB_AT_PM_TWO = '0 14 * * *' diff --git a/apps/common/exceptions.py b/apps/common/exceptions.py index 5fa8861b0..0c22d6723 100644 --- a/apps/common/exceptions.py +++ b/apps/common/exceptions.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # from django.utils.translation import gettext_lazy as _ -from rest_framework.exceptions import APIException from rest_framework import status +from rest_framework.exceptions import APIException class JMSException(APIException): @@ -42,8 +42,11 @@ class ReferencedByOthers(JMSException): class UserConfirmRequired(JMSException): + status_code = status.HTTP_412_PRECONDITION_FAILED + def __init__(self, code=None): detail = { + 'type': 'user_confirm_required', 'code': code, 'detail': _('This action require confirm current user') } diff --git a/apps/common/permissions.py b/apps/common/permissions.py index 5c58de68e..ee2e24a8b 100644 --- a/apps/common/permissions.py +++ b/apps/common/permissions.py @@ -5,12 +5,6 @@ import time from django.conf import settings from rest_framework import permissions -from authentication.const import ConfirmType -from authentication.models import ConnectionToken -from common.exceptions import UserConfirmRequired -from common.utils import get_object_or_none -from orgs.utils import tmp_to_root_org - class IsValidUser(permissions.IsAuthenticated): """Allows access to valid user, is active and not expired""" @@ -20,21 +14,6 @@ class IsValidUser(permissions.IsAuthenticated): and request.user.is_valid -class IsValidUserOrConnectionToken(IsValidUser): - def has_permission(self, request, view): - return super().has_permission(request, view) \ - or self.is_valid_connection_token(request) - - @staticmethod - def is_valid_connection_token(request): - token_id = request.query_params.get('token') - if not token_id: - return False - with tmp_to_root_org(): - token = get_object_or_none(ConnectionToken, id=token_id) - return token and token.is_valid - - class OnlySuperUser(IsValidUser): def has_permission(self, request, view): return super().has_permission(request, view) \ @@ -56,33 +35,36 @@ class WithBootstrapToken(permissions.BasePermission): return settings.BOOTSTRAP_TOKEN == request_bootstrap_token -class UserConfirmation(permissions.BasePermission): - ttl = 60 * 5 - min_level = 1 - confirm_type = ConfirmType.ReLogin - +class ServiceAccountSignaturePermission(permissions.BasePermission): def has_permission(self, request, view): - if not settings.SECURITY_VIEW_AUTH_NEED_MFA: + from authentication.models import AccessKey + from common.utils.crypto import get_aes_crypto + signature = request.META.get('HTTP_X_JMS_SVC', '') + if not signature or not signature.startswith('Sign'): + return False + data = signature[4:].strip() + if not data or ':' not in data: + return False + ak_id, time_sign = data.split(':', 1) + if not ak_id or not time_sign: + return False + ak = AccessKey.objects.filter(id=ak_id).first() + if not ak or not ak.is_active: + return False + if not ak.user or not ak.user.is_active or not ak.user.is_service_account: + return False + aes = get_aes_crypto(str(ak.secret).replace('-', ''), mode='ECB') + try: + timestamp = aes.decrypt(time_sign) + if not timestamp or not timestamp.isdigit(): + return False + timestamp = int(timestamp) + interval = abs(int(time.time()) - timestamp) + if interval > 30: + return False return True + except Exception: + return False - confirm_level = request.session.get('CONFIRM_LEVEL') - confirm_time = request.session.get('CONFIRM_TIME') - ttl = self.get_ttl() - if not confirm_level or not confirm_time or \ - confirm_level < self.min_level or \ - confirm_time < time.time() - ttl: - raise UserConfirmRequired(code=self.confirm_type) - return True - - def get_ttl(self): - if self.confirm_type == ConfirmType.MFA: - ttl = settings.SECURITY_MFA_VERIFY_TTL - else: - ttl = self.ttl - return ttl - - @classmethod - def require(cls, confirm_type=ConfirmType.ReLogin, ttl=60 * 5): - min_level = ConfirmType.values.index(confirm_type) + 1 - name = 'UserConfirmationLevel{}TTL{}'.format(min_level, ttl) - return type(name, (cls,), {'min_level': min_level, 'ttl': ttl, 'confirm_type': confirm_type}) + def has_object_permission(self, request, view, obj): + return False diff --git a/apps/common/sdk/sms/custom.py b/apps/common/sdk/sms/custom.py index 64f9a0246..2cfaca6ca 100644 --- a/apps/common/sdk/sms/custom.py +++ b/apps/common/sdk/sms/custom.py @@ -30,7 +30,7 @@ class CustomSMS(BaseSMSClient): code=template_param.get('code'), phone_numbers=phone_numbers_str ) - logger.info(f'Custom sms send: phone_numbers={phone_numbers}param={params}') + logger.info(f'Custom sms send: phone_numbers={phone_numbers}, param={params}') if settings.CUSTOM_SMS_REQUEST_METHOD == 'post': action = requests.post kwargs = {'json': params} diff --git a/apps/common/sdk/sms/custom_file.py b/apps/common/sdk/sms/custom_file.py new file mode 100644 index 000000000..ee24a0d31 --- /dev/null +++ b/apps/common/sdk/sms/custom_file.py @@ -0,0 +1,50 @@ +import os + +from collections import OrderedDict + +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from django.utils.module_loading import import_string + +from common.utils import get_logger +from common.exceptions import JMSException +from jumpserver.settings import get_file_md5 + +from .base import BaseSMSClient + + +logger = get_logger(__file__) + + +custom_sms_method = None +SMS_CUSTOM_FILE_MD5 = settings.SMS_CUSTOM_FILE_MD5 +SMS_CUSTOM_FILE_PATH = os.path.join(settings.PROJECT_DIR, 'data', 'sms', 'main.py') +if SMS_CUSTOM_FILE_MD5 == get_file_md5(SMS_CUSTOM_FILE_PATH): + try: + custom_sms_method_path = 'data.sms.main.send_sms' + custom_sms_method = import_string(custom_sms_method_path) + except Exception as e: + logger.warning('Import custom sms method failed: {}, Maybe not enabled'.format(e)) + + +class CustomFileSMS(BaseSMSClient): + @classmethod + def new_from_settings(cls): + return cls() + + @staticmethod + def need_pre_check(): + return False + + def send_sms(self, phone_numbers: list, template_param: OrderedDict, **kwargs): + if not callable(custom_sms_method): + raise JMSException(_('The custom sms file is invalid')) + + try: + logger.info(f'Custom file sms send: phone_numbers={phone_numbers}, param={template_param}') + custom_sms_method(phone_numbers, template_param, **kwargs) + except Exception as err: + raise JMSException(_('SMS sending failed[%s]: %s') % (f"{_('Custom type')}({_('File')})", err)) + + +client = CustomFileSMS diff --git a/apps/common/sdk/sms/endpoint.py b/apps/common/sdk/sms/endpoint.py index ce016888a..6f5433fa5 100644 --- a/apps/common/sdk/sms/endpoint.py +++ b/apps/common/sdk/sms/endpoint.py @@ -17,7 +17,8 @@ class BACKENDS(TextChoices): TENCENT = 'tencent', _('Tencent cloud') HUAWEI = 'huawei', _('Huawei Cloud') CMPP2 = 'cmpp2', _('CMPP v2.0') - Custom = 'custom', _('Custom type') + CUSTOM = 'custom', _('Custom type') + CUSTOM_FILE = 'custom_file', f"{_('Custom type')}({_('File')})" class SMS: diff --git a/apps/common/serializers/fields.py b/apps/common/serializers/fields.py index 844c424e5..5fe019c65 100644 --- a/apps/common/serializers/fields.py +++ b/apps/common/serializers/fields.py @@ -218,12 +218,13 @@ class PhoneField(serializers.CharField): code = data.get('code') phone = data.get('phone', '') if code and phone: - data = '{}{}'.format(code, phone) + code = code.replace('+', '') + data = '+{}{}'.format(code, phone) else: data = phone try: phone = phonenumbers.parse(data, 'CN') - data = '{}{}'.format(phone.country_code, phone.national_number) + data = '+{}{}'.format(phone.country_code, phone.national_number) except phonenumbers.NumberParseException: data = '+86{}'.format(data) diff --git a/apps/common/tasks.py b/apps/common/tasks.py index a7d9aacdd..468fa2a51 100644 --- a/apps/common/tasks.py +++ b/apps/common/tasks.py @@ -36,8 +36,8 @@ def send_mail_async(*args, **kwargs): args[0] = (settings.EMAIL_SUBJECT_PREFIX or '') + args[0] from_email = settings.EMAIL_FROM or settings.EMAIL_HOST_USER args.insert(2, from_email) - args = tuple(args) + args = tuple(args) try: return send_mail(*args, **kwargs) except Exception as e: diff --git a/apps/common/utils/common.py b/apps/common/utils/common.py index 56f1dbd09..063bd1ebc 100644 --- a/apps/common/utils/common.py +++ b/apps/common/utils/common.py @@ -17,6 +17,8 @@ import psutil from django.conf import settings from django.templatetags.static import static +from common.permissions import ServiceAccountSignaturePermission + UUID_PATTERN = re.compile(r'\w{8}(-\w{4}){3}-\w{12}') ipip_db = None @@ -153,19 +155,26 @@ def is_uuid(seq): def get_request_ip(request): - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',') + x_real_ip = request.META.get('HTTP_X_REAL_IP', '') + if x_real_ip: + return x_real_ip + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',') if x_forwarded_for and x_forwarded_for[0]: login_ip = x_forwarded_for[0] - else: - login_ip = request.META.get('REMOTE_ADDR', '') + return login_ip + + login_ip = request.META.get('REMOTE_ADDR', '') return login_ip def get_request_ip_or_data(request): ip = '' - if hasattr(request, 'data'): - ip = request.data.get('remote_addr', '') + + if hasattr(request, 'data') and request.data.get('remote_addr', ''): + permission = ServiceAccountSignaturePermission() + if permission.has_permission(request, None): + ip = request.data.get('remote_addr', '') ip = ip or get_request_ip(request) return ip diff --git a/apps/common/utils/random.py b/apps/common/utils/random.py index 267de8d22..505fbd041 100644 --- a/apps/common/utils/random.py +++ b/apps/common/utils/random.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # import random +import secrets import socket import string import struct @@ -17,32 +18,37 @@ def random_ip(): return socket.inet_ntoa(struct.pack('>I', random.randint(1, 0xffffffff))) +def random_replace_char(s, chars, length): + using_index = set() + seq = list(s) + + while length > 0: + index = secrets.randbelow(len(seq) - 1) + if index in using_index or index == 0: + continue + seq[index] = secrets.choice(chars) + using_index.add(index) + length -= 1 + return ''.join(seq) + + def random_string(length: int, lower=True, upper=True, digit=True, special_char=False, symbols=string_punctuation): - random.seed() - args_names = ['lower', 'upper', 'digit'] - args_values = [lower, upper, digit] - args_string = [string.ascii_lowercase, string.ascii_uppercase, string.digits] - args_string_map = dict(zip(args_names, args_string)) - kwargs = dict(zip(args_names, args_values)) - kwargs_keys = list(kwargs.keys()) - kwargs_values = list(kwargs.values()) - args_true_count = len([i for i in kwargs_values if i]) + if not any([lower, upper, digit]): + raise ValueError('At least one of `lower`, `upper`, `digit` must be `True`') + if length < 4: + raise ValueError('The length of the string must be greater than 3') - assert any(kwargs_values), f'Parameters {kwargs_keys} must have at least one `True`' - assert length >= args_true_count, f'Expected length >= {args_true_count}, bug got {length}' - - chars = ''.join([args_string_map[k] for k, v in kwargs.items() if v]) - password = list(random.choice(chars) for i in range(length)) + chars_map = ( + (lower, string.ascii_lowercase), + (upper, string.ascii_uppercase), + (digit, string.digits), + ) + chars = ''.join([i[1] for i in chars_map if i[0]]) + texts = list(secrets.choice(chars) for __ in range(length)) + texts = ''.join(texts) + # 控制一下特殊字符的数量, 别随机出来太多 if special_char: - special_num = length // 16 + 1 - special_index = [] - for i in range(special_num): - index = random.randint(1, length - 1) - if index not in special_index: - special_index.append(index) - for i in special_index: - password[i] = random.choice(symbols) - - password = ''.join(password) - return password + symbol_num = length // 16 + 1 + texts = random_replace_char(texts, symbols, symbol_num) + return texts diff --git a/apps/common/utils/verify_code.py b/apps/common/utils/verify_code.py index b21e43b26..f9a721c89 100644 --- a/apps/common/utils/verify_code.py +++ b/apps/common/utils/verify_code.py @@ -67,7 +67,7 @@ class SendAndVerifyCodeUtil(object): return cache.get(self.key) def __generate(self): - code = random_string(4, lower=False, upper=False) + code = random_string(settings.SMS_CODE_LENGTH, lower=False, upper=False) self.code = code return code diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index b24ef5746..6ba54732d 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -247,10 +247,11 @@ class Config(dict): 'AUTH_CUSTOM': False, 'AUTH_CUSTOM_FILE_MD5': '', - # Custom Config 'MFA_CUSTOM': False, 'MFA_CUSTOM_FILE_MD5': '', + 'SMS_CUSTOM_FILE_MD5': '', + # 临时密码 'AUTH_TEMP_TOKEN': False, @@ -409,6 +410,7 @@ class Config(dict): 'SMS_ENABLED': False, 'SMS_BACKEND': '', + 'SMS_CODE_LENGTH': 4, 'SMS_TEST_PHONE': '', 'ALIBABA_ACCESS_KEY_ID': '', @@ -439,7 +441,7 @@ class Config(dict): 'CMPP2_VERIFY_TEMPLATE_CODE': '{code}', 'CUSTOM_SMS_URL': '', - 'CUSTOM_SMS_API_PARAMS': {'phone_numbers': '{phone_numbers}', 'code': '{code}'}, + 'CUSTOM_SMS_API_PARAMS': {'phone_numbers': '{phone_numbers}', 'content': _('The verification code is: {code}')}, 'CUSTOM_SMS_REQUEST_METHOD': 'get', # Email @@ -495,7 +497,7 @@ class Config(dict): 'SECURITY_LUNA_REMEMBER_AUTH': True, 'SECURITY_WATERMARK_ENABLED': True, 'SECURITY_MFA_VERIFY_TTL': 3600, - 'SECURITY_UNCOMMON_USERS_TTL': 30, + 'SECURITY_UNCOMMON_USERS_TTL': 90, 'VERIFY_CODE_TTL': 60, 'SECURITY_SESSION_SHARE': True, 'SECURITY_CHECK_DIFFERENT_CITY_LOGIN': True, @@ -543,12 +545,12 @@ class Config(dict): 'MAGNUS_ORACLE_PORTS': '30000-30030', # 记录清理清理 - 'LOGIN_LOG_KEEP_DAYS': 200, - 'TASK_LOG_KEEP_DAYS': 90, - 'OPERATE_LOG_KEEP_DAYS': 200, - 'ACTIVITY_LOG_KEEP_DAYS': 200, - 'FTP_LOG_KEEP_DAYS': 200, - 'CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS': 30, + 'LOGIN_LOG_KEEP_DAYS': 180, + 'TASK_LOG_KEEP_DAYS': 180, + 'OPERATE_LOG_KEEP_DAYS': 180, + 'ACTIVITY_LOG_KEEP_DAYS': 180, + 'FTP_LOG_KEEP_DAYS': 180, + 'CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS': 180, 'TICKETS_ENABLED': True, diff --git a/apps/jumpserver/settings/_xpack.py b/apps/jumpserver/settings/_xpack.py index 9f4319a35..6e43c7501 100644 --- a/apps/jumpserver/settings/_xpack.py +++ b/apps/jumpserver/settings/_xpack.py @@ -1,17 +1,27 @@ # -*- coding: utf-8 -*- # +import datetime import os -from .. import const + from .base import INSTALLED_APPS, TEMPLATES +from .. import const + +current_year = datetime.datetime.now().year +corporation = f'FIT2CLOUD 飞致云 © 2014-{current_year}' XPACK_DIR = os.path.join(const.BASE_DIR, 'xpack') XPACK_ENABLED = os.path.isdir(XPACK_DIR) XPACK_TEMPLATES_DIR = [] XPACK_CONTEXT_PROCESSOR = [] +XPACK_LICENSE_IS_VALID = False +XPACK_LICENSE_INFO = { + 'corporation': corporation, +} if XPACK_ENABLED: from xpack.utils import get_xpack_templates_dir, get_xpack_context_processor + INSTALLED_APPS.insert(0, 'xpack.apps.XpackConfig') XPACK_TEMPLATES_DIR = get_xpack_templates_dir(const.BASE_DIR) XPACK_CONTEXT_PROCESSOR = get_xpack_context_processor() diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index 63a96fe1d..5b6a1cd35 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -259,6 +259,9 @@ if MFA_CUSTOM and MFA_CUSTOM_FILE_MD5 == get_file_md5(MFA_CUSTOM_FILE_PATH): # 自定义多因子认证模块 MFA_BACKENDS.append(MFA_BACKEND_CUSTOM) +SMS_CUSTOM_FILE_MD5 = CONFIG.SMS_CUSTOM_FILE_MD5 +SMS_CUSTOM_FILE_PATH = os.path.join(PROJECT_DIR, 'data', 'sms', 'main.py') + AUTHENTICATION_BACKENDS_THIRD_PARTY = [ AUTH_BACKEND_OIDC_CODE, AUTH_BACKEND_CAS, AUTH_BACKEND_SAML2, AUTH_BACKEND_OAUTH2 diff --git a/apps/jumpserver/settings/base.py b/apps/jumpserver/settings/base.py index 4cf1c3c77..4344660f7 100644 --- a/apps/jumpserver/settings/base.py +++ b/apps/jumpserver/settings/base.py @@ -79,6 +79,7 @@ if CONFIG.SITE_URL: ALLOWED_DOMAINS = DOMAINS.split(',') if DOMAINS else ['localhost:8080'] ALLOWED_DOMAINS = [host.strip() for host in ALLOWED_DOMAINS] ALLOWED_DOMAINS = [host.replace('http://', '').replace('https://', '') for host in ALLOWED_DOMAINS if host] +ALLOWED_DOMAINS = [host.replace(':80', '').replace(':443', '') for host in ALLOWED_DOMAINS] ALLOWED_DOMAINS = [host.split('/')[0] for host in ALLOWED_DOMAINS if host] DEBUG_HOSTS = ('127.0.0.1', 'localhost', 'core') diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 18d0bccca..0ec5c54ff 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -124,6 +124,8 @@ TASK_LOG_KEEP_DAYS = CONFIG.TASK_LOG_KEEP_DAYS OPERATE_LOG_KEEP_DAYS = CONFIG.OPERATE_LOG_KEEP_DAYS ACTIVITY_LOG_KEEP_DAYS = CONFIG.ACTIVITY_LOG_KEEP_DAYS FTP_LOG_KEEP_DAYS = CONFIG.FTP_LOG_KEEP_DAYS +CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS = CONFIG.CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS + ORG_CHANGE_TO_URL = CONFIG.ORG_CHANGE_TO_URL WINDOWS_SKIP_ALL_MANUAL_PASSWORD = CONFIG.WINDOWS_SKIP_ALL_MANUAL_PASSWORD @@ -159,8 +161,6 @@ 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 - TERMINAL_RAZOR_ENABLED = CONFIG.TERMINAL_RAZOR_ENABLED TERMINAL_OMNIDB_ENABLED = CONFIG.TERMINAL_OMNIDB_ENABLED TERMINAL_MAGNUS_ENABLED = CONFIG.TERMINAL_MAGNUS_ENABLED @@ -169,6 +169,7 @@ TERMINAL_KOKO_SSH_ENABLED = CONFIG.TERMINAL_KOKO_SSH_ENABLED # SMS enabled SMS_ENABLED = CONFIG.SMS_ENABLED SMS_BACKEND = CONFIG.SMS_BACKEND +SMS_CODE_LENGTH = CONFIG.SMS_CODE_LENGTH SMS_TEST_PHONE = CONFIG.SMS_TEST_PHONE # Alibaba @@ -186,6 +187,11 @@ TENCENT_VERIFY_SIGN_NAME = CONFIG.TENCENT_VERIFY_SIGN_NAME TENCENT_VERIFY_TEMPLATE_CODE = CONFIG.TENCENT_VERIFY_TEMPLATE_CODE TENCENT_SMS_SIGN_AND_TEMPLATES = CONFIG.TENCENT_SMS_SIGN_AND_TEMPLATES +# CUSTOM_SMS +CUSTOM_SMS_URL = CONFIG.CUSTOM_SMS_URL +CUSTOM_SMS_API_PARAMS = CONFIG.CUSTOM_SMS_API_PARAMS +CUSTOM_SMS_REQUEST_METHOD = CONFIG.CUSTOM_SMS_REQUEST_METHOD + # 公告 ANNOUNCEMENT_ENABLED = CONFIG.ANNOUNCEMENT_ENABLED ANNOUNCEMENT = CONFIG.ANNOUNCEMENT diff --git a/apps/jumpserver/utils.py b/apps/jumpserver/utils.py index 24f2b10d7..350c9b346 100644 --- a/apps/jumpserver/utils.py +++ b/apps/jumpserver/utils.py @@ -1,9 +1,7 @@ # -*- coding: utf-8 -*- # -from datetime import datetime from functools import partial -from django.conf import settings from werkzeug.local import LocalProxy from common.local import thread_local @@ -21,25 +19,4 @@ def get_current_request(): return _find('current_request') -def has_valid_xpack_license(): - if not settings.XPACK_ENABLED: - return False - from xpack.plugins.license.models import License - return License.has_valid_license() - - -def get_xpack_license_info() -> dict: - if has_valid_xpack_license(): - from xpack.plugins.license.models import License - info = License.get_license_detail() - corporation = info.get('corporation', '') - else: - current_year = datetime.now().year - corporation = f'FIT2CLOUD 飞致云 © 2014-{current_year}' - info = { - 'corporation': corporation - } - return info - - current_request = LocalProxy(partial(_find, 'current_request')) diff --git a/apps/locale/ja/LC_MESSAGES/django.mo b/apps/locale/ja/LC_MESSAGES/django.mo index e12251412..65199d7a6 100644 --- a/apps/locale/ja/LC_MESSAGES/django.mo +++ b/apps/locale/ja/LC_MESSAGES/django.mo @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2ffdd50c364a510b4f5cfe7922a5f1a604e8bc7b03aa43ece1dff0250ccde6d6 -size 160575 +oid sha256:67b3061a082605d0862007d0deb03dba0477b3eac85d99faf5755ee44e7453eb +size 163187 diff --git a/apps/locale/ja/LC_MESSAGES/django.po b/apps/locale/ja/LC_MESSAGES/django.po index aa30a95b7..7eff444d0 100644 --- a/apps/locale/ja/LC_MESSAGES/django.po +++ b/apps/locale/ja/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-09-19 15:41+0800\n" +"POT-Creation-Date: 2023-10-19 16:21+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME{% trans 'JumpServer Client, currently used to launch the client, now only support launch RDP SSH client, The Telnet client will next' %}
diff --git a/apps/terminal/api/applet/applet.py b/apps/terminal/api/applet/applet.py index 90b4f162c..9e656487b 100644 --- a/apps/terminal/api/applet/applet.py +++ b/apps/terminal/api/applet/applet.py @@ -61,7 +61,7 @@ class DownloadUploadMixin: update = request.query_params.get('update') is_enterprise = manifest.get('edition') == Applet.Edition.enterprise - if is_enterprise and not settings.XPACK_ENABLED: + if is_enterprise and not settings.XPACK_LICENSE_IS_VALID: raise ValidationError({'error': _('This is enterprise edition applet')}) instance = Applet.objects.filter(name=name).first() diff --git a/apps/terminal/api/component/endpoint.py b/apps/terminal/api/component/endpoint.py index e2eeaf548..b40aba9aa 100644 --- a/apps/terminal/api/component/endpoint.py +++ b/apps/terminal/api/component/endpoint.py @@ -6,8 +6,8 @@ from rest_framework.request import Request from rest_framework.response import Response from assets.models import Asset +from authentication.permissions import IsValidUserOrConnectionToken from common.api import JMSBulkModelViewSet -from common.permissions import IsValidUserOrConnectionToken from orgs.utils import tmp_to_root_org from terminal import serializers from terminal.models import Session, Endpoint, EndpointRule diff --git a/apps/terminal/api/session/session.py b/apps/terminal/api/session/session.py index 756fb5a91..c286dee83 100644 --- a/apps/terminal/api/session/session.py +++ b/apps/terminal/api/session/session.py @@ -92,7 +92,12 @@ class SessionViewSet(RecordViewLogMixin, OrgBulkModelViewSet): rbac_perms = { 'download': ['terminal.download_sessionreplay'], } - permission_classes = [RBACPermission | IsSessionAssignee] + permission_classes = [RBACPermission] + + def get_permissions(self): + if self.action == 'retrieve': + self.permission_classes = [RBACPermission | IsSessionAssignee] + return super().get_permissions() @staticmethod def prepare_offline_file(session, local_path): diff --git a/apps/terminal/api/session/task.py b/apps/terminal/api/session/task.py index f9dc8a2f2..fd09fb2ae 100644 --- a/apps/terminal/api/session/task.py +++ b/apps/terminal/api/session/task.py @@ -50,14 +50,13 @@ class TaskViewSet(JMSBulkModelViewSet): serializer.is_valid(raise_exception=True) session_id = serializer.validated_data['session_id'] task_name = serializer.validated_data['task_name'] - session_ids = [session_id, ] user_id = request.user.id - for session_id in session_ids: - if not is_session_approver(session_id, user_id): - return Response({}, status=status.HTTP_403_FORBIDDEN) - with tmp_to_root_org(): - validated_session = create_sessions_tasks(session_ids, request.user, task_name=task_name) + if not is_session_approver(session_id, user_id): + return Response({}, status=status.HTTP_403_FORBIDDEN) + + with tmp_to_root_org(): + validated_session = create_sessions_tasks([session_id], request.user, task_name=task_name) return Response({"ok": validated_session}) diff --git a/apps/terminal/connect_methods.py b/apps/terminal/connect_methods.py index 3f290a0c0..9c7ee989a 100644 --- a/apps/terminal/connect_methods.py +++ b/apps/terminal/connect_methods.py @@ -75,7 +75,7 @@ class NativeClient(TextChoices): xpack_protocols = Protocol.xpack_protocols() for protocol, _clients in clients_map.items(): - if not settings.XPACK_ENABLED and protocol in xpack_protocols: + if not settings.XPACK_LICENSE_IS_VALID and protocol in xpack_protocols: continue if isinstance(_clients, dict): if os == 'all': @@ -83,7 +83,7 @@ class NativeClient(TextChoices): else: _clients = _clients.get(os, _clients['default']) for client in _clients: - if not settings.XPACK_ENABLED and client in cls.xpack_methods(): + if not settings.XPACK_LICENSE_IS_VALID and client in cls.xpack_methods(): continue methods[protocol].append({ 'value': client.value, @@ -144,7 +144,7 @@ class ConnectMethodUtil: 'support': [ Protocol.mysql, Protocol.postgresql, Protocol.oracle, Protocol.sqlserver, - Protocol.mariadb + Protocol.mariadb, Protocol.db2 ], 'match': 'm2m' }, diff --git a/apps/terminal/const.py b/apps/terminal/const.py index c047343a4..9f0b8a947 100644 --- a/apps/terminal/const.py +++ b/apps/terminal/const.py @@ -86,11 +86,18 @@ class SessionType(TextChoices): class ActionPermission(TextChoices): - readonly = "readonly", _('Read Only') + readonly = "readonly", _('Read only') writable = "writable", _('Writable') class TaskNameType(TextChoices): - kill_session = "kill_session", _('Kill Session') - lock_session = "lock_session", _('Lock Session') - unlock_session = "unlock_session", _('Unlock Session') + kill_session = "kill_session", _('Kill session') + lock_session = "lock_session", _('Lock session') + unlock_session = "unlock_session", _('Unlock session') + + +class SessionErrorReason(TextChoices): + connect_failed = 'connect_failed', _('Connect failed') + replay_create_failed = 'replay_create_failed', _('Replay create failed') + replay_upload_failed = 'replay_upload_failed', _('Replay upload failed') + replay_convert_failed = 'replay_convert_failed', _('Replay convert failed') diff --git a/apps/terminal/migrations/0062_applet_edition.py b/apps/terminal/migrations/0062_applet_edition.py index bd5118edf..8ca93ce2f 100644 --- a/apps/terminal/migrations/0062_applet_edition.py +++ b/apps/terminal/migrations/0062_applet_edition.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='applet', name='edition', - field=models.CharField(choices=[('community', 'Community'), ('enterprise', 'Enterprise')], + field=models.CharField(choices=[('community', 'Community edition'), ('enterprise', 'Enterprise')], default='community', max_length=128, verbose_name='Edition'), ), ] diff --git a/apps/terminal/migrations/0064_auto_20230728_1001.py b/apps/terminal/migrations/0064_auto_20230728_1001.py index 900abb5ba..93af187c4 100644 --- a/apps/terminal/migrations/0064_auto_20230728_1001.py +++ b/apps/terminal/migrations/0064_auto_20230728_1001.py @@ -33,6 +33,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='task', name='name', - field=models.CharField(choices=[('kill_session', 'Kill Session'), ('lock_session', 'Lock Session'), ('unlock_session', 'Unlock Session')], max_length=128, verbose_name='Name'), + field=models.CharField(choices=[('kill_session', 'Kill session'), ('lock_session', 'Lock session'), ('unlock_session', 'Unlock session')], max_length=128, verbose_name='Name'), ), ] diff --git a/apps/terminal/migrations/0065_session_error_reason.py b/apps/terminal/migrations/0065_session_error_reason.py new file mode 100644 index 000000000..1a3bf8f67 --- /dev/null +++ b/apps/terminal/migrations/0065_session_error_reason.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.10 on 2023-10-10 06:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('terminal', '0064_auto_20230728_1001'), + ] + + operations = [ + migrations.AddField( + model_name='session', + name='error_reason', + field=models.CharField(blank=True, max_length=128, verbose_name='Error reason'), + ), + ] diff --git a/apps/terminal/models/applet/applet.py b/apps/terminal/models/applet/applet.py index 99f4e1689..23450aff3 100644 --- a/apps/terminal/models/applet/applet.py +++ b/apps/terminal/models/applet/applet.py @@ -163,7 +163,7 @@ class Applet(JMSBaseModel): counts[host_id] += 1 hosts = list(sorted(hosts, key=lambda h: counts[h.id])) - return hosts[0] + return hosts[0] if hosts else None def select_host(self, user, asset): hosts = self.hosts.filter(is_active=True) @@ -186,6 +186,8 @@ class Applet(JMSBaseModel): host = pref_host[0] else: host = self._select_by_load(hosts) + if host is None: + return cache.set(prefer_key, str(host.id), timeout=None) return host diff --git a/apps/terminal/models/session/session.py b/apps/terminal/models/session/session.py index 09f2df9e1..8530c3c63 100644 --- a/apps/terminal/models/session/session.py +++ b/apps/terminal/models/session/session.py @@ -45,6 +45,7 @@ class Session(OrgModelMixin): date_end = models.DateTimeField(verbose_name=_("Date end"), null=True) comment = models.TextField(blank=True, null=True, verbose_name=_("Comment")) cmd_amount = models.IntegerField(default=-1, verbose_name=_("Command amount")) + error_reason = models.CharField(max_length=128, blank=True, verbose_name=_("Error reason")) upload_to = 'replay' ACTIVE_CACHE_KEY_PREFIX = 'SESSION_ACTIVE_{}' diff --git a/apps/terminal/permissions.py b/apps/terminal/permissions.py index e2e72e572..288d839eb 100644 --- a/apps/terminal/permissions.py +++ b/apps/terminal/permissions.py @@ -9,7 +9,7 @@ __all__ = ['IsSessionAssignee'] class IsSessionAssignee(permissions.IsAuthenticated): def has_permission(self, request, view): - return False + return True def has_object_permission(self, request, view, obj): try: diff --git a/apps/terminal/serializers/session.py b/apps/terminal/serializers/session.py index 6e5476c3e..ba8ecfdf8 100644 --- a/apps/terminal/serializers/session.py +++ b/apps/terminal/serializers/session.py @@ -5,7 +5,7 @@ from common.serializers.fields import LabeledChoiceField from common.utils import pretty_string from orgs.mixins.serializers import BulkOrgResourceModelSerializer from .terminal import TerminalSmallSerializer -from ..const import SessionType +from ..const import SessionType, SessionErrorReason from ..models import Session __all__ = [ @@ -24,6 +24,9 @@ class SessionSerializer(BulkOrgResourceModelSerializer): can_join = serializers.BooleanField(read_only=True, label=_("Can join")) can_terminate = serializers.BooleanField(read_only=True, label=_("Can terminate")) asset = serializers.CharField(label=_("Asset"), style={'base_template': 'textarea.html'}) + error_reason = LabeledChoiceField( + choices=SessionErrorReason.choices, label=_("Error reason"), required=False + ) class Meta: model = Session @@ -33,7 +36,7 @@ class SessionSerializer(BulkOrgResourceModelSerializer): "protocol", 'type', "login_from", "remote_addr", "is_success", "is_finished", "has_replay", "has_command", "date_start", "date_end", "comment", "terminal_display", "is_locked", - 'command_amount', + 'command_amount', 'error_reason' ] fields_fk = ["terminal", ] fields_custom = ["can_replay", "can_join", "can_terminate"] diff --git a/apps/terminal/tasks.py b/apps/terminal/tasks.py index 65fae3ded..38ca1941a 100644 --- a/apps/terminal/tasks.py +++ b/apps/terminal/tasks.py @@ -43,6 +43,7 @@ def delete_terminal_status_period(): @register_as_period_task(interval=600) @after_app_ready_start @after_app_shutdown_clean_periodic +@tmp_to_root_org() def clean_orphan_session(): active_sessions = Session.objects.filter(is_finished=False) for session in active_sessions: diff --git a/apps/tickets/api/ticket.py b/apps/tickets/api/ticket.py index 1e44c3818..da60aefe8 100644 --- a/apps/tickets/api/ticket.py +++ b/apps/tickets/api/ticket.py @@ -6,9 +6,10 @@ from rest_framework.decorators import action from rest_framework.exceptions import MethodNotAllowed from rest_framework.response import Response +from audits.handler import create_or_update_operate_log from common.api import CommonApiMixin from common.const.http import POST, PUT, PATCH -from orgs.utils import tmp_to_root_org +from orgs.utils import tmp_to_root_org, tmp_to_org from rbac.permissions import RBACPermission from tickets import filters from tickets import serializers @@ -17,6 +18,7 @@ from tickets.models import ( ApplyLoginAssetTicket, ApplyCommandTicket ) from tickets.permissions.ticket import IsAssignee, IsApplicant +from ..const import TicketAction __all__ = [ 'TicketViewSet', 'ApplyAssetTicketViewSet', @@ -77,6 +79,21 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): with tmp_to_root_org(): return super().create(request, *args, **kwargs) + @staticmethod + def _record_operate_log(ticket, action): + with tmp_to_org(ticket.org_id): + after = { + 'ID': str(ticket.id), + str(_('Name')): ticket.title, + str(_('Applicant')): str(ticket.applicant), + } + object_name = ticket._meta.object_name + resource_type = ticket._meta.verbose_name + create_or_update_operate_log( + action, resource_type, resource=ticket, + after=after, object_name=object_name + ) + @action(detail=True, methods=[PUT, PATCH], permission_classes=[IsAssignee, ]) def approve(self, request, *args, **kwargs): self.ticket_not_allowed() @@ -88,18 +105,21 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) instance = serializer.save() instance.approve(processor=request.user) + self._record_operate_log(instance, TicketAction.approve) return Response('ok') @action(detail=True, methods=[PUT], permission_classes=[IsAssignee, ]) def reject(self, request, *args, **kwargs): instance = self.get_object() instance.reject(processor=request.user) + self._record_operate_log(instance, TicketAction.reject) return Response('ok') @action(detail=True, methods=[PUT], permission_classes=[IsAssignee | IsApplicant, ]) def close(self, request, *args, **kwargs): instance = self.get_object() instance.close() + self._record_operate_log(instance, TicketAction.close) return Response('ok') @action(detail=False, methods=[PUT], permission_classes=[RBACPermission, ]) diff --git a/apps/tickets/models/ticket/command_confirm.py b/apps/tickets/models/ticket/command_confirm.py index f261bb147..62103eb1e 100644 --- a/apps/tickets/models/ticket/command_confirm.py +++ b/apps/tickets/models/ticket/command_confirm.py @@ -18,3 +18,6 @@ class ApplyCommandTicket(Ticket): 'acls.CommandFilterACL', on_delete=models.SET_NULL, null=True, verbose_name=_('Command filter acl') ) + + class Meta: + verbose_name = _('Apply Command Ticket') diff --git a/apps/tickets/models/ticket/login_asset_confirm.py b/apps/tickets/models/ticket/login_asset_confirm.py index ffcbf869e..ce2f7f6fd 100644 --- a/apps/tickets/models/ticket/login_asset_confirm.py +++ b/apps/tickets/models/ticket/login_asset_confirm.py @@ -22,3 +22,6 @@ class ApplyLoginAssetTicket(Ticket): return self.connection_token.is_active = True self.connection_token.save() + + class Meta: + verbose_name = _('Apply Login Asset Ticket') diff --git a/apps/tickets/models/ticket/login_confirm.py b/apps/tickets/models/ticket/login_confirm.py index 024761ac7..7b17dd1f8 100644 --- a/apps/tickets/models/ticket/login_confirm.py +++ b/apps/tickets/models/ticket/login_confirm.py @@ -10,3 +10,6 @@ class ApplyLoginTicket(Ticket): apply_login_ip = models.GenericIPAddressField(verbose_name=_('Login IP'), null=True) apply_login_city = models.CharField(max_length=64, verbose_name=_('Login city'), null=True) apply_login_datetime = models.DateTimeField(verbose_name=_('Login datetime'), null=True) + + class Meta: + verbose_name = _('Apply Login Ticket') diff --git a/apps/tickets/templates/tickets/approve_check_password.html b/apps/tickets/templates/tickets/approve_check_password.html index 9fd7e0862..fe256bc1c 100644 --- a/apps/tickets/templates/tickets/approve_check_password.html +++ b/apps/tickets/templates/tickets/approve_check_password.html @@ -39,7 +39,6 @@