From c465fccc33b77ca4b271ab4ebc26b862bfa8728c Mon Sep 17 00:00:00 2001 From: "Jiangjie.Bai" <32935519+BaiJiangJie@users.noreply.github.com> Date: Tue, 7 Sep 2021 18:16:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20Session=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=85=B1=E4=BA=AB=20(#6768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 添加 SECURITY_SESSION_SHARE 配置 * feat: 添加 SessionShare / ShareJoinRecord Model * feat: 添加 SessionShare / ShareJoinRecord Model * feat: 添加 SessionSharing / SessionJoinRecord Model * feat: 添加 SessionSharing API * feat: 添加 SessionJoinRecord API * feat: 修改迁移文件 * feat: 修改迁移文件 * feat: 修改迁移文件 * feat: 修改API权限 --- apps/jumpserver/conf.py | 1 + apps/jumpserver/settings/custom.py | 1 + apps/settings/api/common.py | 3 +- apps/settings/serializers/settings.py | 4 + apps/terminal/api/__init__.py | 1 + apps/terminal/api/sharing.py | 79 +++++++++++ .../0040_sessionjoinrecord_sessionsharing.py | 59 +++++++++ apps/terminal/models/__init__.py | 1 + apps/terminal/models/sharing.py | 124 ++++++++++++++++++ apps/terminal/serializers/__init__.py | 1 + apps/terminal/serializers/sharing.py | 57 ++++++++ apps/terminal/urls/api_urls.py | 2 + 12 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 apps/terminal/api/sharing.py create mode 100644 apps/terminal/migrations/0040_sessionjoinrecord_sessionsharing.py create mode 100644 apps/terminal/models/sharing.py create mode 100644 apps/terminal/serializers/sharing.py diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 311a58159..47af0c38b 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -268,6 +268,7 @@ class Config(dict): 'SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER': '', 'SECURITY_LUNA_REMEMBER_AUTH': True, 'SECURITY_WATERMARK_ENABLED': True, + 'SECURITY_SESSION_SHARE': True, 'HTTP_BIND_HOST': '0.0.0.0', 'HTTP_LISTEN_PORT': 8080, diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 2e31eb536..d586796ce 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -129,6 +129,7 @@ HEALTH_CHECK_TOKEN = CONFIG.HEALTH_CHECK_TOKEN TERMINAL_RDP_ADDR = CONFIG.TERMINAL_RDP_ADDR SECURITY_LUNA_REMEMBER_AUTH = CONFIG.SECURITY_LUNA_REMEMBER_AUTH SECURITY_WATERMARK_ENABLED = CONFIG.SECURITY_WATERMARK_ENABLED +SECURITY_SESSION_SHARE = CONFIG.SECURITY_SESSION_SHARE LOGIN_REDIRECT_TO_BACKEND = CONFIG.LOGIN_REDIRECT_TO_BACKEND diff --git a/apps/settings/api/common.py b/apps/settings/api/common.py index 5f0e6f89c..74a76c646 100644 --- a/apps/settings/api/common.py +++ b/apps/settings/api/common.py @@ -131,7 +131,8 @@ class PublicSettingApi(generics.RetrieveAPIView): "AUTH_WECOM": settings.AUTH_WECOM, "AUTH_DINGTALK": settings.AUTH_DINGTALK, "AUTH_FEISHU": settings.AUTH_FEISHU, - 'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED + 'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED, + 'SECURITY_SESSION_SHARE': settings.SECURITY_SESSION_SHARE } } return instance diff --git a/apps/settings/serializers/settings.py b/apps/settings/serializers/settings.py index 6c0dadb14..fbdb75f2c 100644 --- a/apps/settings/serializers/settings.py +++ b/apps/settings/serializers/settings.py @@ -164,6 +164,10 @@ class SecuritySettingSerializer(serializers.Serializer): required=True, label=_('Replay watermark'), help_text=_('Enabled, the session replay contains watermark information') ) + SECURITY_SESSION_SHARE = serializers.BooleanField( + required=True, label=_('Session share'), + help_text=_("Enabled, Allows user active session to be shared with other users") + ) SECURITY_LOGIN_LIMIT_COUNT = serializers.IntegerField( min_value=3, max_value=99999, label=_('Limit the number of login failures') diff --git a/apps/terminal/api/__init__.py b/apps/terminal/api/__init__.py index e6a3b3885..fec6da11e 100644 --- a/apps/terminal/api/__init__.py +++ b/apps/terminal/api/__init__.py @@ -6,3 +6,4 @@ from .command import * from .task import * from .storage import * from .status import * +from .sharing import * diff --git a/apps/terminal/api/sharing.py b/apps/terminal/api/sharing.py new file mode 100644 index 000000000..3fe5ca45c --- /dev/null +++ b/apps/terminal/api/sharing.py @@ -0,0 +1,79 @@ +from rest_framework.exceptions import MethodNotAllowed, ValidationError +from rest_framework.decorators import action +from rest_framework.response import Response +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from common.permissions import IsAppUser, IsSuperUser +from common.const.http import PATCH +from orgs.mixins.api import OrgModelViewSet +from .. import serializers, models + +__all__ = ['SessionSharingViewSet', 'SessionJoinRecordsViewSet'] + + +class SessionSharingViewSet(OrgModelViewSet): + serializer_class = serializers.SessionSharingSerializer + permission_classes = (IsAppUser | IsSuperUser, ) + search_fields = ('session', 'creator', 'is_active', 'expired_time') + filterset_fields = search_fields + model = models.SessionSharing + + def get_permissions(self): + if self.request.method.lower() in ['post']: + self.permission_classes = (IsAppUser,) + return super().get_permissions() + + def create(self, request, *args, **kwargs): + if not settings.SECURITY_SESSION_SHARE: + detail = _('Secure session sharing settings is disabled') + raise MethodNotAllowed(self.action, detail=detail) + return super().create(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(self.action) + + +class SessionJoinRecordsViewSet(OrgModelViewSet): + serializer_class = serializers.SessionJoinRecordSerializer + permission_classes = (IsAppUser | IsSuperUser, ) + search_fields = ( + 'sharing', 'session', 'joiner', 'date_joined', 'date_left', + 'login_from', 'is_success', 'is_finished' + ) + filterset_fields = search_fields + model = models.SessionJoinRecord + + def get_permissions(self): + if self.request.method.lower() in ['post']: + self.permission_classes = (IsAppUser,) + return super().get_permissions() + + def create(self, request, *args, **kwargs): + try: + response = super().create(request, *args, **kwargs) + except ValidationError as e: + error = e.args[0] if e.args else '' + response = Response( + data={'error': str(error)}, status=e.status_code + ) + return response + + def perform_create(self, serializer): + instance = serializer.save() + self.can_join(instance) + + @staticmethod + def can_join(instance): + can_join, reason = instance.can_join() + if not can_join: + instance.join_failed(reason=reason) + raise ValidationError(reason) + + @action(methods=[PATCH], detail=True) + def finished(self, request, *args, **kwargs): + instance = self.get_object() + instance.finished() + return Response(data={'msg': 'ok'}) + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(self.action) diff --git a/apps/terminal/migrations/0040_sessionjoinrecord_sessionsharing.py b/apps/terminal/migrations/0040_sessionjoinrecord_sessionsharing.py new file mode 100644 index 000000000..a2e0933a8 --- /dev/null +++ b/apps/terminal/migrations/0040_sessionjoinrecord_sessionsharing.py @@ -0,0 +1,59 @@ +# Generated by Django 3.1.12 on 2021-09-07 09:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('terminal', '0039_auto_20210805_1552'), + ] + + operations = [ + migrations.CreateModel( + name='SessionSharing', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('verify_code', models.CharField(max_length=16, verbose_name='Verify code')), + ('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Active')), + ('expired_time', models.IntegerField(db_index=True, default=0, verbose_name='Expired time (min)')), + ('creator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Creator')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='terminal.session', verbose_name='Session')), + ], + options={ + 'ordering': ('-date_created',), + }, + ), + migrations.CreateModel( + name='SessionJoinRecord', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('verify_code', models.CharField(max_length=16, verbose_name='Verify code')), + ('date_joined', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Date joined')), + ('date_left', models.DateTimeField(db_index=True, null=True, verbose_name='Date left')), + ('remote_addr', models.CharField(blank=True, db_index=True, max_length=128, null=True, verbose_name='Remote addr')), + ('login_from', models.CharField(choices=[('ST', 'SSH Terminal'), ('RT', 'RDP Terminal'), ('WT', 'Web Terminal')], default='WT', max_length=2, verbose_name='Login from')), + ('is_success', models.BooleanField(db_index=True, default=True, verbose_name='Success')), + ('reason', models.CharField(blank=True, default='-', max_length=1024, null=True, verbose_name='Reason')), + ('is_finished', models.BooleanField(db_index=True, default=False, verbose_name='Finished')), + ('joiner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Joiner')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='terminal.session', verbose_name='Session')), + ('sharing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='terminal.sessionsharing', verbose_name='Session sharing')), + ], + options={ + 'ordering': ('-date_joined',), + }, + ), + ] diff --git a/apps/terminal/models/__init__.py b/apps/terminal/models/__init__.py index 1de5fd31e..ef5c759a6 100644 --- a/apps/terminal/models/__init__.py +++ b/apps/terminal/models/__init__.py @@ -4,3 +4,4 @@ from .status import * from .storage import * from .task import * from .terminal import * +from .sharing import * diff --git a/apps/terminal/models/sharing.py b/apps/terminal/models/sharing.py new file mode 100644 index 000000000..46b4eb1e8 --- /dev/null +++ b/apps/terminal/models/sharing.py @@ -0,0 +1,124 @@ +from django.db import models +import datetime +from common.mixins import CommonModelMixin +from orgs.mixins.models import OrgModelMixin +from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone +from .session import Session + + +__all__ = ['SessionSharing', 'SessionJoinRecord'] + + +class SessionSharing(CommonModelMixin, OrgModelMixin): + session = models.ForeignKey( + 'terminal.Session', on_delete=models.CASCADE, verbose_name=_('Session') + ) + # creator / created_by + creator = models.ForeignKey( + 'users.User', on_delete=models.CASCADE, blank=True, null=True, + verbose_name=_('Creator') + ) + verify_code = models.CharField(max_length=16, verbose_name=_('Verify code')) + is_active = models.BooleanField( + default=True, verbose_name=_('Active'), db_index=True + ) + expired_time = models.IntegerField( + default=0, verbose_name=_('Expired time (min)'), db_index=True + ) + + class Meta: + ordering = ('-date_created', ) + + def __str__(self): + return 'Creator: {}'.format(self.creator) + + @property + def date_expired(self): + return self.date_created + datetime.timedelta(minutes=self.expired_time) + + @property + def is_expired(self): + if timezone.now() > self.date_expired: + return False + return True + + def can_join(self): + if not self.is_active: + return False, _('Link not active') + if not self.is_expired: + return False, _('Link expired') + return True, '' + + +class SessionJoinRecord(CommonModelMixin, OrgModelMixin): + LOGIN_FROM = Session.LOGIN_FROM + + session = models.ForeignKey( + 'terminal.Session', on_delete=models.CASCADE, verbose_name=_('Session') + ) + verify_code = models.CharField(max_length=16, verbose_name=_('Verify code')) + sharing = models.ForeignKey( + SessionSharing, on_delete=models.CASCADE, + verbose_name=_('Session sharing') + ) + joiner = models.ForeignKey( + 'users.User', on_delete=models.CASCADE, blank=True, null=True, + verbose_name=_('Joiner') + ) + date_joined = models.DateTimeField( + auto_now_add=True, verbose_name=_("Date joined"), db_index=True, + ) + date_left = models.DateTimeField( + verbose_name=_("Date left"), null=True, db_index=True + ) + remote_addr = models.CharField( + max_length=128, verbose_name=_("Remote addr"), blank=True, null=True, + db_index=True + ) + login_from = models.CharField( + max_length=2, choices=LOGIN_FROM.choices, default="WT", + verbose_name=_("Login from") + ) + is_success = models.BooleanField( + default=True, db_index=True, verbose_name=_('Success') + ) + reason = models.CharField( + max_length=1024, default='-', blank=True, null=True, + verbose_name=_('Reason') + ) + is_finished = models.BooleanField( + default=False, db_index=True, verbose_name=_('Finished') + ) + + class Meta: + ordering = ('-date_joined', ) + + def __str__(self): + return 'Joiner: {}'.format(self.joiner) + + @property + def joiner_display(self): + return str(self.joiner) + + def can_join(self): + # sharing + sharing_can_join, reason = self.sharing.can_join() + if not sharing_can_join: + return False, reason + # self + if self.verify_code != self.sharing.verify_code: + return False, _('Invalid verification code') + return True, '' + + def join_failed(self, reason): + self.is_success = False + self.reason = reason[:1024] + self.save() + + def finished(self): + if self.is_finished: + return + self.date_left = timezone.now() + self.is_finished = True + self.save() diff --git a/apps/terminal/serializers/__init__.py b/apps/terminal/serializers/__init__.py index f1714dc21..a2a5bbf30 100644 --- a/apps/terminal/serializers/__init__.py +++ b/apps/terminal/serializers/__init__.py @@ -4,3 +4,4 @@ from .terminal import * from .session import * from .storage import * from .command import * +from .sharing import * diff --git a/apps/terminal/serializers/sharing.py b/apps/terminal/serializers/sharing.py new file mode 100644 index 000000000..5e8568cf5 --- /dev/null +++ b/apps/terminal/serializers/sharing.py @@ -0,0 +1,57 @@ +from rest_framework import serializers +from django.utils.translation import ugettext_lazy as _ +from orgs.mixins.serializers import OrgResourceModelSerializerMixin +from common.utils.random import random_string +from ..models import SessionSharing, SessionJoinRecord + +__all__ = ['SessionSharingSerializer', 'SessionJoinRecordSerializer'] + + +class SessionSharingSerializer(OrgResourceModelSerializerMixin): + class Meta: + model = SessionSharing + fields_mini = ['id'] + fields_small = fields_mini + [ + 'verify_code', 'is_active', 'expired_time', 'created_by', + 'date_created', 'date_updated' + ] + fields_fk = ['session', 'creator'] + fields = fields_small + fields_fk + read_only_fields = ['verify_code'] + + def create(self, validated_data): + validated_data['verify_code'] = random_string(4) + session = validated_data.get('session') + if session: + validated_data['creator_id'] = session.user_id + validated_data['created_by'] = str(session.user) + validated_data['org_id'] = session.org_id + return super().create(validated_data) + + +class SessionJoinRecordSerializer(OrgResourceModelSerializerMixin): + class Meta: + model = SessionJoinRecord + fields_mini = ['id'] + fields_small = fields_mini + [ + 'joiner_display', 'verify_code', 'date_joined', 'date_left', + 'remote_addr', 'login_from', 'is_success', 'reason', 'is_finished', + 'created_by', 'date_created', 'date_updated' + ] + fields_fk = ['session', 'sharing', 'joiner'] + fields = fields_small + fields_fk + extra_kwargs = { + 'session': {'required': False}, + 'joiner': {'required': True}, + 'sharing': {'required': True}, + 'remote_addr': {'required': True}, + 'verify_code': {'required': True}, + 'joiner_display': {'label': _('Joiner')}, + } + + def create(self, validate_data): + sharing = validate_data.get('sharing') + if sharing: + validate_data['session'] = sharing.session + validate_data['org_id'] = sharing.org_id + return super().create(validate_data) diff --git a/apps/terminal/urls/api_urls.py b/apps/terminal/urls/api_urls.py index 57fb6eb73..edbf9db23 100644 --- a/apps/terminal/urls/api_urls.py +++ b/apps/terminal/urls/api_urls.py @@ -20,6 +20,8 @@ router.register(r'commands', api.CommandViewSet, 'command') router.register(r'status', api.StatusViewSet, 'status') router.register(r'replay-storages', api.ReplayStorageViewSet, 'replay-storage') router.register(r'command-storages', api.CommandStorageViewSet, 'command-storage') +router.register(r'session-sharings', api.SessionSharingViewSet, 'session-sharing') +router.register(r'session-join-records', api.SessionJoinRecordsViewSet, 'session-sharing-record') urlpatterns = [ path('terminal-registrations/', api.TerminalRegistrationApi.as_view(), name='terminal-registration'),