import base64 import json import os import urllib.parse from django.conf import settings from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.translation import gettext_lazy as _ from rest_framework import status, serializers from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError 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_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, WebMethod from terminal.models import EndpointRule, Endpoint from users.const import FileNameConflictResolution from users.const import RDPSmartSize, RDPColorQuality from users.models import Preference from .face import FaceMonitorContext from ..mixins import AuthFaceMixin from ..models import ConnectionToken, date_expired_default from ..serializers import ( ConnectionTokenSerializer, ConnectionTokenSecretSerializer, SuperConnectionTokenSerializer, ConnectTokenAppletOptionSerializer, ConnectionTokenReusableSerializer, ConnectTokenVirtualAppOptionSerializer ) __all__ = ['ConnectionTokenViewSet', 'SuperConnectionTokenViewSet'] logger = get_logger(__name__) class RDPFileClientProtocolURLMixin: request: Request get_serializer: callable def get_rdp_file_info(self, token: ConnectionToken): rdp_options = { 'full address:s': '', 'username:s': '', 'use multimon:i': '0', 'audiomode:i': '0', 'disable wallpaper:i': '0', 'disable full window drag:i': '0', 'disable menu anims:i': '0', 'disable themes:i': '0', 'alternate shell:s': '', 'shell working directory:s': '', 'authentication level:i': '2', 'connect to console:i': '0', 'disable cursor setting:i': '0', 'allow font smoothing:i': '1', 'allow desktop composition:i': '1', 'redirectprinters:i': '0', 'prompt for credentials on client:i': '0', 'autoreconnection enabled:i': '1', 'bookmarktype:i': '3', 'use redirection server name:i': '0', } # copy from # https://learn.microsoft.com/zh-cn/windows-server/administration/performance-tuning/role/remote-desktop/session-hosts rdp_low_speed_broadband_option = { "connection type:i": 2, "disable wallpaper:i": 1, "bitmapcachepersistenable:i": 1, "disable full window drag:i": 1, "disable menu anims:i": 1, "allow font smoothing:i": 0, "allow desktop composition:i": 0, "disable themes:i": 0 } rdp_high_speed_broadband_option = { "connection type:i": 4, "disable wallpaper:i": 0, "bitmapcachepersistenable:i": 1, "disable full window drag:i": 1, "disable menu anims:i": 0, "allow font smoothing:i": 0, "allow desktop composition:i": 1, "disable themes:i": 0 } RDP_CONNECTION_SPEED_OPTION_MAP = { "auto": {}, "low_speed_broadband": rdp_low_speed_broadband_option, "high_speed_broadband": rdp_high_speed_broadband_option, } # 设置多屏显示 multi_mon = is_true(self.request.query_params.get('multi_mon')) if multi_mon: rdp_options['use multimon:i'] = '1' # 设置磁盘挂载 drives_redirect = is_true(self.request.query_params.get('drives_redirect')) if drives_redirect: if ActionChoices.contains(token.actions, ActionChoices.transfer()): rdp_options['drivestoredirect:s'] = '*' # 设置全屏 full_screen = is_true(self.request.query_params.get('full_screen')) rdp_options['screen mode id:i'] = '2' if full_screen else '1' # 设置 RDP Server 地址 endpoint = self.get_smart_endpoint(protocol='rdp', asset=token.asset) rdp_options['full address:s'] = f'{endpoint.host}:{endpoint.rdp_port}' # 设置用户名 rdp_options['username:s'] = '{}|{}'.format(token.user.username, str(token.id)) # rdp_options['domain:s'] = token.account_ad_domain # 设置宽高 resolution_value = token.connect_options.get('resolution', 'auto') if resolution_value != 'auto': width, height = resolution_value.split('x') if width and height: rdp_options['desktopwidth:i'] = width rdp_options['desktopheight:i'] = height rdp_options['winposstr:s'] = f'0,1,0,0,{width},{height}' rdp_options['dynamic resolution:i'] = '0' color_quality = self.request.query_params.get('rdp_color_quality') color_quality = color_quality if color_quality else os.getenv('JUMPSERVER_COLOR_DEPTH', RDPColorQuality.HIGH) # 设置其他选项 rdp_options['session bpp:i'] = color_quality rdp_options['audiomode:i'] = self.parse_env_bool('JUMPSERVER_DISABLE_AUDIO', 'false', '2', '0') rdp_options['smart sizing:i'] = self.request.query_params.get('rdp_smart_size', RDPSmartSize.DISABLE) # 设置远程应用, 不是 Mstsc if token.connect_method != NativeClient.mstsc: remote_app_options = token.get_remote_app_option() rdp_options.update(remote_app_options) rdp = token.asset.platform.protocols.filter(name='rdp').first() if rdp and rdp.setting.get('console'): rdp_options['administrative session:i'] = '1' rdp_connection_speed = token.connect_options.get('rdp_connection_speed', 'auto') rdp_options.update(RDP_CONNECTION_SPEED_OPTION_MAP.get(rdp_connection_speed, {})) # 文件名 name = token.asset.name prefix_name = f'{token.user.username}-{name}' filename = self.get_connect_filename(prefix_name) content = '' for k, v in rdp_options.items(): content += f'{k}:{v}\n' return filename, content @staticmethod def escape_name(name): name = name.replace('/', '_') name = name.replace('\\', '_') name = name.replace('.', '_') name = urllib.parse.quote(name) return name def get_connect_filename(self, prefix_name): filename = f'{prefix_name}-jumpserver' filename = self.escape_name(filename) return filename @staticmethod def parse_env_bool(env_key, env_default, true_value, false_value): return true_value if is_true(os.getenv(env_key, env_default)) else false_value def get_client_protocol_data(self, token: ConnectionToken): _os = get_request_os(self.request) connect_method_name = token.connect_method connect_method_dict = ConnectMethodUtil.get_connect_method( token.connect_method, token.protocol, _os ) asset = token.asset if connect_method_dict is None: raise ValueError('Connect method not support: {}'.format(connect_method_name)) account = token.account or token.input_username datetime = timezone.localtime(timezone.now()).strftime('%Y-%m-%d_%H:%M:%S') name = account + '@' + asset.name + '[' + datetime + ']' data = { 'version': 2, 'id': str(token.id), # 兼容老的,未来几个版本删掉 'value': token.value, # 兼容老的,未来几个版本删掉 'name': self.escape_name(name), 'protocol': token.protocol, 'token': { 'id': str(token.id), 'value': token.value, }, 'file': {}, 'command': '' } if connect_method_name == NativeClient.mstsc or connect_method_dict['type'] == 'applet': filename, content = self.get_rdp_file_info(token) data.update({ 'protocol': 'rdp', 'file': { 'name': filename, 'content': content, } }) else: endpoint = self.get_smart_endpoint( protocol=connect_method_dict['endpoint_protocol'], asset=asset ) data.update({ 'asset': { 'id': str(asset.id), 'category': asset.category, 'type': asset.type, 'name': asset.name, 'address': asset.address, 'info': { **asset.spec_info, } }, 'endpoint': { 'host': endpoint.host, 'port': endpoint.get_port(token.asset, token.protocol), } }) return data def get_smart_endpoint(self, protocol, asset=None): endpoint = Endpoint.match_by_instance_label(asset, protocol, self.request) if not endpoint: target_ip = asset.get_target_ip() if asset else '' endpoint = EndpointRule.match_endpoint( target_instance=asset, target_ip=target_ip, protocol=protocol, request=self.request ) return endpoint class ExtraActionApiMixin(RDPFileClientProtocolURLMixin): request: Request get_object: callable get_serializer: callable perform_create: callable validate_exchange_token: callable need_face_verify: bool create_face_verify: callable @action(methods=['POST', 'GET'], detail=True, url_path='rdp-file') def get_rdp_file(self, request, *args, **kwargs): token = self.get_object() token.is_valid() filename, content = self.get_rdp_file_info(token) response = HttpResponse(content, content_type='application/octet-stream') if is_true(request.query_params.get('reusable')): token.set_reusable(True) filename = '{}-{}'.format(filename, token.date_expired.strftime('%Y%m%d_%H%M%S')) filename += '.rdp' response['Content-Disposition'] = 'attachment; filename*=UTF-8\'\'%s' % filename return response @action(methods=['POST', 'GET'], detail=True, url_path='client-url') def get_client_protocol_url(self, *args, **kwargs): token = self.get_object() token.is_valid() try: protocol_data = self.get_client_protocol_data(token) except ValueError as e: return Response(data={'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) protocol_data = json.dumps(protocol_data).encode() protocol_data = base64.b64encode(protocol_data).decode() data = { 'url': 'jms://{}'.format(protocol_data) } return Response(data=data) @action(methods=['PATCH'], detail=True) def expire(self, request, *args, **kwargs): instance = self.get_object() instance.expire() return Response(status=status.HTTP_204_NO_CONTENT) @action(methods=['PATCH'], detail=True, url_path='reuse') def reuse(self, request, *args, **kwargs): instance = self.get_object() if not settings.CONNECTION_TOKEN_REUSABLE: error = _('Reusable connection token is not allowed, global setting not enabled') raise serializers.ValidationError(error) serializer = self.get_serializer(instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) is_reusable = serializer.validated_data.get('is_reusable', False) instance.set_reusable(is_reusable) return Response(data=serializer.data) @action(methods=['POST'], detail=False) def exchange(self, request, *args, **kwargs): pk = request.data.get('id', None) or request.data.get('pk', None) # 只能兑换自己使用的 Token instance = get_object_or_404(ConnectionToken, pk=pk, user=request.user) instance.id = None self.validate_exchange_token(instance) instance.date_expired = date_expired_default() instance.save() serializer = self.get_serializer(instance) response = Response(serializer.data, status=status.HTTP_201_CREATED) if self.need_face_verify: self.create_face_verify(response) return response class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixin, JMSModelViewSet): filterset_fields = ( 'user_display', 'asset_display' ) search_fields = filterset_fields serializer_classes = { 'default': ConnectionTokenSerializer, 'reuse': ConnectionTokenReusableSerializer, } http_method_names = ['get', 'post', 'patch', 'head', 'options', 'trace'] rbac_perms = { 'list': 'authentication.view_connectiontoken', 'retrieve': 'authentication.view_connectiontoken', 'create': 'authentication.add_connectiontoken', 'exchange': 'authentication.add_connectiontoken', 'reuse': 'authentication.reuse_connectiontoken', 'expire': 'authentication.expire_connectiontoken', 'get_rdp_file': 'authentication.add_connectiontoken', 'get_client_protocol_url': 'authentication.add_connectiontoken', } input_username = '' need_face_verify = False face_monitor_token = '' def get_queryset(self): queryset = ConnectionToken.objects \ .filter(user=self.request.user) \ .filter(date_expired__gt=timezone.now()) return queryset def get_user(self, serializer): return self.request.user def perform_create(self, serializer): self.validate_serializer(serializer) return super().perform_create(serializer) def _insert_connect_options(self, data, user): connect_options = data.pop('connect_options', {}) 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') protocol = data.get('protocol') connect_method = data.get('connect_method') self.input_username = self.get_input_username(data) _data = self._validate(user, asset, account_name, protocol, connect_method) data.update(_data) return serializer def validate_exchange_token(self, token): user = token.user asset = token.asset account_name = token.account _data = self._validate(user, asset, account_name, token.protocol, token.connect_method) for k, v in _data.items(): setattr(token, k, v) return token def _validate(self, user, asset, account_name, protocol, connect_method): data = dict() data['org_id'] = asset.org_id data['user'] = user data['value'] = random_string(16) if account_name == AliasAccount.ANON and asset.category not in ['web', 'custom']: raise ValidationError(_('Anonymous account is not supported for this asset')) account = self._validate_perm(user, asset, account_name, protocol) if account.has_secret: data['input_secret'] = '' if account.username != AliasAccount.INPUT: data['input_username'] = '' ticket = self._validate_acl(user, asset, account, connect_method) if ticket: data['from_ticket'] = ticket if ticket or self.need_face_verify: data['is_active'] = False if self.face_monitor_token: FaceMonitorContext.get_or_create_context(self.face_monitor_token, self.request.user.id) data['face_monitor_token'] = self.face_monitor_token return data @staticmethod def _validate_perm(user, asset, account_name, protocol): from perms.utils.asset_perm import PermAssetDetailUtil account = PermAssetDetailUtil(user, asset).validate_permission(account_name, protocol) if not account or not account.actions: msg = _('Account not found') raise JMSException(code='perm_account_invalid', detail=msg) if account.date_expired < timezone.now(): msg = _('Permission expired') 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, connect_method): from acls.models import LoginAssetACL kwargs = {'user': user, 'asset': asset, 'account': account} if account.username == AliasAccount.INPUT: kwargs['account_username'] = self.input_username acls = LoginAssetACL.filter_queryset(**kwargs) 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=self.input_username, assignees=acl.reviewers.all(), org_id=asset.org_id ) return ticket if acl.is_action(acl.ActionChoices.face_verify): if not self.request.query_params.get('face_verify'): msg = _('ACL action is face verify') raise JMSException(code='acl_face_verify', detail=msg) self.need_face_verify = True if acl.is_action(acl.ActionChoices.face_online): if connect_method not in [WebMethod.web_cli, WebMethod.web_gui]: msg = _('ACL action not supported for this asset') raise JMSException(detail=msg, code='acl_face_online_not_supported') face_verify = self.request.query_params.get('face_verify') face_monitor_token = self.request.query_params.get('face_monitor_token') if not face_verify or not face_monitor_token: msg = _('ACL action is face online') raise JMSException(code='acl_face_online', detail=msg) self.need_face_verify = True self.face_monitor_token = face_monitor_token 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, account, self.input_username ).publish_async() def create_face_verify(self, response): if not self.request.user.face_vector: raise JMSException(code='no_face_feature', detail=_('No available face feature')) connection_token_id = response.data.get('id') context_data = { "action": "login_asset", "connection_token_id": connection_token_id, } face_verify_token = self.create_face_verify_context(context_data) response.data['face_token'] = face_verify_token def create(self, request, *args, **kwargs): try: response = super().create(request, *args, **kwargs) if self.need_face_verify: self.create_face_verify(response) except JMSException as e: data = {'code': e.detail.code, 'detail': e.detail} return Response(data, status=e.status_code) return response class SuperConnectionTokenViewSet(ConnectionTokenViewSet): serializer_classes = { 'default': SuperConnectionTokenSerializer, 'get_secret_detail': ConnectionTokenSecretSerializer, } rbac_perms = { 'create': 'authentication.add_superconnectiontoken', 'renewal': 'authentication.add_superconnectiontoken', 'check': 'authentication.view_superconnectiontoken', 'get_secret_detail': 'authentication.view_superconnectiontokensecret', 'get_applet_info': 'authentication.view_superconnectiontoken', 'release_applet_account': 'authentication.view_superconnectiontoken', 'get_virtual_app_info': 'authentication.view_superconnectiontoken', } def get_queryset(self): return ConnectionToken.objects.all() def get_user(self, serializer): return serializer.validated_data.get('user') @action(methods=['GET'], detail=True, url_path='check') def check(self, request, *args, **kwargs): instance = self.get_object() data = { "detail": "OK", "code": "perm_ok", "expired": instance.is_expired } try: self._validate_perm( instance.user, instance.asset, instance.account, instance.protocol ) except JMSException as e: data['code'] = e.detail.code data['detail'] = str(e.detail) return Response(data=data, status=status.HTTP_400_BAD_REQUEST) return Response(data=data, status=status.HTTP_200_OK) @action(methods=['PATCH'], detail=False) def renewal(self, request, *args, **kwargs): from common.utils.timezone import as_current_tz token_id = request.data.get('id') or '' token = get_object_or_404(ConnectionToken, pk=token_id) date_expired = as_current_tz(token.date_expired) if token.is_expired: raise PermissionDenied('Token is expired at: {}'.format(date_expired)) token.renewal() data = { 'ok': True, 'msg': f'Token is renewed, date expired: {date_expired}' } return Response(data=data, status=status.HTTP_200_OK) @action(methods=['POST'], detail=False, url_path='secret') def get_secret_detail(self, request, *args, **kwargs): """ 非常重要的 api, 在逻辑层再判断一下 rbac 权限, 双重保险 """ rbac_perm = 'authentication.view_superconnectiontokensecret' if not request.user.has_perm(rbac_perm): raise PermissionDenied('Not allow to view secret') token_id = request.data.get('id') or '' token = get_object_or_404(ConnectionToken, pk=token_id) token.is_valid() serializer = self.get_serializer(instance=token) expire_now = request.data.get('expire_now', True) asset_type = token.asset.type # 设置默认值 if asset_type in ['k8s', 'kubernetes']: expire_now = False if token.is_reusable and settings.CONNECTION_TOKEN_REUSABLE: logger.debug('Token is reusable, not expire now') elif is_false(expire_now): logger.debug('Api specified, now expire now') else: token.expire() return Response(serializer.data, status=status.HTTP_200_OK) @action(methods=['POST'], detail=False, url_path='applet-option') def get_applet_info(self, *args, **kwargs): token_id = self.request.data.get('id') token = get_object_or_404(ConnectionToken, pk=token_id) if token.is_expired: return Response({'error': 'Token expired'}, status=status.HTTP_400_BAD_REQUEST) data = token.get_applet_option() serializer = ConnectTokenAppletOptionSerializer(data) return Response(serializer.data) @action(methods=['POST'], detail=False, url_path='virtual-app-option') def get_virtual_app_info(self, *args, **kwargs): token_id = self.request.data.get('id') token = get_object_or_404(ConnectionToken, pk=token_id) if token.is_expired: return Response({'error': 'Token expired'}, status=status.HTTP_400_BAD_REQUEST) data = token.get_virtual_app_option() serializer = ConnectTokenVirtualAppOptionSerializer(data) return Response(serializer.data) @action(methods=['DELETE', 'POST'], detail=False, url_path='applet-account/release') def release_applet_account(self, *args, **kwargs): lock_key = self.request.data.get('id') released = ConnectionToken.release_applet_account(lock_key) if released: logger.debug('Release applet account success: {}'.format(lock_key)) return Response({'msg': 'released'}) else: logger.error('Release applet account error: {}'.format(lock_key)) return Response({'error': 'not found or expired'}, status=400)