feat: 支持 Session 会话共享 (#6768)

* 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权限
pull/6772/head
Jiangjie.Bai 2021-09-07 18:16:27 +08:00 committed by GitHub
parent 3d934dc7c0
commit c465fccc33
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 332 additions and 1 deletions

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -6,3 +6,4 @@ from .command import *
from .task import *
from .storage import *
from .status import *
from .sharing import *

View File

@ -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)

View File

@ -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',),
},
),
]

View File

@ -4,3 +4,4 @@ from .status import *
from .storage import *
from .task import *
from .terminal import *
from .sharing import *

View File

@ -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()

View File

@ -4,3 +4,4 @@ from .terminal import *
from .session import *
from .storage import *
from .command import *
from .sharing import *

View File

@ -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)

View File

@ -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'),