mirror of https://github.com/jumpserver/jumpserver
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
parent
3d934dc7c0
commit
c465fccc33
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -6,3 +6,4 @@ from .command import *
|
|||
from .task import *
|
||||
from .storage import *
|
||||
from .status import *
|
||||
from .sharing import *
|
||||
|
|
|
@ -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)
|
|
@ -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',),
|
||||
},
|
||||
),
|
||||
]
|
|
@ -4,3 +4,4 @@ from .status import *
|
|||
from .storage import *
|
||||
from .task import *
|
||||
from .terminal import *
|
||||
from .sharing import *
|
||||
|
|
|
@ -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()
|
|
@ -4,3 +4,4 @@ from .terminal import *
|
|||
from .session import *
|
||||
from .storage import *
|
||||
from .command import *
|
||||
from .sharing import *
|
||||
|
|
|
@ -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)
|
|
@ -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'),
|
||||
|
|
Loading…
Reference in New Issue