diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 718f8fe7e..9327fe80b 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -457,9 +457,6 @@ class Config(dict): 'TERMINAL_SESSION_KEEP_DURATION': 200, 'TERMINAL_HOST_KEY': '', 'TERMINAL_COMMAND_STORAGE': {}, - # Luna 页面 - # 默认图形化分辨率 - 'TERMINAL_GRAPHICAL_RESOLUTION': 'Auto', # 未来废弃(目前迁移会用) 'TERMINAL_RDP_ADDR': '', # 保留(Luna还在用) diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 9688f2c82..0bc8b380b 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -91,8 +91,6 @@ TERMINAL_HOST_KEY = CONFIG.TERMINAL_HOST_KEY TERMINAL_HEADER_TITLE = CONFIG.TERMINAL_HEADER_TITLE # TERMINAL_TELNET_REGEX = CONFIG.TERMINAL_TELNET_REGEX -# 默认图形化分辨率 -TERMINAL_GRAPHICAL_RESOLUTION = CONFIG.TERMINAL_GRAPHICAL_RESOLUTION # Asset user auth external backend, default AuthBook backend BACKEND_ASSET_USER_AUTH_VAULT = False diff --git a/apps/settings/serializers/public.py b/apps/settings/serializers/public.py index 02f900d5f..370727b2c 100644 --- a/apps/settings/serializers/public.py +++ b/apps/settings/serializers/public.py @@ -45,8 +45,6 @@ class PrivateSettingSerializer(PublicSettingSerializer): TERMINAL_KOKO_SSH_ENABLED = serializers.BooleanField() TERMINAL_OMNIDB_ENABLED = serializers.BooleanField() - TERMINAL_GRAPHICAL_RESOLUTION = serializers.CharField() - ANNOUNCEMENT_ENABLED = serializers.BooleanField() ANNOUNCEMENT = serializers.DictField() diff --git a/apps/settings/serializers/terminal.py b/apps/settings/serializers/terminal.py index 17782a2e0..42e03eeb7 100644 --- a/apps/settings/serializers/terminal.py +++ b/apps/settings/serializers/terminal.py @@ -39,16 +39,3 @@ class TerminalSettingSerializer(serializers.Serializer): TERMINAL_MAGNUS_ENABLED = serializers.BooleanField(label=_("Enable database proxy")) TERMINAL_RAZOR_ENABLED = serializers.BooleanField(label=_("Enable Razor")) TERMINAL_KOKO_SSH_ENABLED = serializers.BooleanField(label=_("Enable SSH Client")) - - RESOLUTION_CHOICES = ( - ('Auto', 'Auto'), - ('1024x768', '1024x768'), - ('1366x768', '1366x768'), - ('1600x900', '1600x900'), - ('1920x1080', '1920x1080') - ) - TERMINAL_GRAPHICAL_RESOLUTION = serializers.ChoiceField( - default='Auto', choices=RESOLUTION_CHOICES, required=False, - label=_('Default graphics resolution'), - help_text=_('Tip: Default resolution to use when connecting graphical assets in Luna pages') - ) diff --git a/apps/users/api/__init__.py b/apps/users/api/__init__.py index 6231e874b..8553b4bc6 100644 --- a/apps/users/api/__init__.py +++ b/apps/users/api/__init__.py @@ -2,6 +2,7 @@ # from .group import * +from .preference import * from .profile import * from .relation import * from .service import * diff --git a/apps/users/api/preference.py b/apps/users/api/preference.py new file mode 100644 index 000000000..1255d6b84 --- /dev/null +++ b/apps/users/api/preference.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# +from rest_framework import generics +from rest_framework.serializers import Serializer + +from common.permissions import IsValidUser +from common.utils import get_logger +from .. import serializers +from ..models import Preference + +logger = get_logger(__file__) + + +class PreferenceApi(generics.RetrieveUpdateAPIView): + permission_classes = (IsValidUser,) + queryset = Preference.objects.all() + serializer_class_mapper = { + 'lina': serializers.LinaSerializer, + 'luna': serializers.LunaSerializer, + 'koko': serializers.KokoSerializer, + } + + def check_permissions(self, request): + if self.category not in self.serializer_class_mapper: + return self.permission_denied(request, 'category is invalid') + return super().check_permissions(request) + + @property + def user(self): + return self.request.user + + @property + def category(self): + return self.request.query_params.get('category') + + def get_serializer_class(self): + cls = self.serializer_class_mapper.get(self.category) + return cls + + def get_field_defaults(self, serializer): + field_defaults = {} + fields = serializer.get_fields() + for name, field in fields.items(): + if isinstance(field, Serializer): + field_defaults[name] = self.get_field_defaults(field) + continue + field_defaults[name] = getattr(field, 'default', None) + return field_defaults + + def get_encrypted_fields(self, serializer): + encrypted_fields = [] + fields = serializer.get_fields() + for name, field in fields.items(): + if isinstance(field, Serializer): + encrypted_fields += self.get_encrypted_fields(field) + continue + if not field.write_only: + continue + encrypted_fields.append(name) + return encrypted_fields + + def get_object(self): + serializer = self.get_serializer_class()() + field_defaults = self.get_field_defaults(serializer) + + qs = self.queryset.filter(user=self.user, category=self.category) + if not qs.exists(): + return field_defaults + + data = dict(qs.values_list('name', 'value')) + for k, v in data.items(): + for d in field_defaults.values(): + if k in d: + d[k] = v + break + return field_defaults + + def perform_update(self, serializer): + user = self.user + category = self.category + model = self.queryset.model + encrypted_fields = self.get_encrypted_fields(serializer) + data = serializer.validated_data + for d in data.values(): + for name, value in d.items(): + kwargs = {'name': name, 'user': user} + defaults = {'category': category} + if name in encrypted_fields: + value = model.encrypt(value) + defaults['encrypted'] = True + defaults['value'] = value + defaults.update(kwargs) + model.objects.update_or_create(defaults, **kwargs) diff --git a/apps/users/api/profile.py b/apps/users/api/profile.py index b916c47a9..727c87314 100644 --- a/apps/users/api/profile.py +++ b/apps/users/api/profile.py @@ -3,24 +3,23 @@ import uuid from rest_framework import generics from rest_framework.permissions import IsAuthenticated + +from authentication.models import ConnectionToken from common.permissions import IsValidUserOrConnectionToken from common.utils import get_object_or_none from orgs.utils import tmp_to_root_org -from authentication.models import ConnectionToken - from users.notifications import ( ResetPasswordMsg, ResetPasswordSuccessMsg, ResetSSHKeyMsg, ResetPublicKeySuccessMsg, ) - +from .mixins import UserQuerysetMixin from .. import serializers from ..models import User -from .mixins import UserQuerysetMixin __all__ = [ 'UserResetPasswordApi', 'UserResetPKApi', 'UserProfileApi', 'UserPasswordApi', - 'UserSecretKeyApi', 'UserPublicKeyApi' + 'UserPublicKeyApi' ] @@ -82,14 +81,6 @@ class UserPasswordApi(generics.RetrieveUpdateAPIView): return resp -class UserSecretKeyApi(generics.RetrieveUpdateAPIView): - permission_classes = (IsAuthenticated,) - serializer_class = serializers.UserUpdateSecretKeySerializer - - def get_object(self): - return self.request.user - - class UserPublicKeyApi(generics.RetrieveUpdateAPIView): permission_classes = (IsAuthenticated,) serializer_class = serializers.UserUpdatePublicKeySerializer diff --git a/apps/users/const.py b/apps/users/const.py index 0de518098..4929f58bd 100644 --- a/apps/users/const.py +++ b/apps/users/const.py @@ -17,3 +17,37 @@ class SystemOrOrgRole(TextChoices): class PasswordStrategy(TextChoices): email = 'email', _('Reset link will be generated and sent to the user') custom = 'custom', _('Set password') + + +class RDPResolution(TextChoices): + AUTO = 'auto', _('AUTO') + RES_1024x768 = '1024x768', '1024x768' + RES_1366x768 = '1366x768', '1366x768' + RES_1600x900 = '1600x900', '1600x900' + RES_1920x1080 = '1920x1080', '1920x1080' + + +class RDPClientOption(TextChoices): + FULL_SCREEN = 'full_screen', _('Full screen') + MULTI_SCREEN = 'multi_screen', _('Multi screen') + DRIVES_REDIRECT = 'drives_redirect', _('Drives redirect') + + +class KeyboardLayout(TextChoices): + EN_US_QWERTY = 'en-us-qwerty', 'US English (Qwerty)' + EN_UK_QWERTY = 'en-gb-qwerty', 'UK English (Qwerty)' + JA_JP_QWERTY = 'ja-jp-qwerty', 'Japanese (Qwerty)' + FR_FR_AZERTY = 'fr-fr-azerty', 'French (Azerty)' + FR_CH_QWERTZ = 'fr-ch-qwertz', 'Swiss French (Qwertz)' + FR_BE_AZERTY = 'fr-be-azerty', 'Belgian French (Azerty)' + TR_TR_QWERTY = 'tr-tr-qwerty', 'Turkish-Q (Qwerty)' + + +class RemoteApplicationConnectionMethod(TextChoices): + WEB = 'web', _('Web') + CLIENT = 'client', _('Client') + + +class FileNameConflictResolution(TextChoices): + REPLACE = 'replace', _('Replace') + SUFFIX = 'suffix', _('Suffix') diff --git a/apps/users/migrations/0043_remove_user_secret_key_preference.py b/apps/users/migrations/0043_remove_user_secret_key_preference.py new file mode 100644 index 000000000..a97044b66 --- /dev/null +++ b/apps/users/migrations/0043_remove_user_secret_key_preference.py @@ -0,0 +1,77 @@ +# Generated by Django 4.1.10 on 2023-09-09 14:16 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +from common.db.utils import Encryptor + + +def migrate_secret_key(apps, *args): + user_model = apps.get_model('users', 'User') + preference_model = apps.get_model('users', 'Preference') + data = user_model.objects.filter( + secret_key__isnull=False + ).values_list('id', 'secret_key') + objs = [] + for user_id, secret_key in data: + secret_key = Encryptor(secret_key).encrypt() + objs.append( + preference_model( + name='secret_key', category='lina', + value=secret_key, encrypted=True, user_id=user_id + ) + ) + preference_model.objects.bulk_create(objs) + + +def migrate_graphical_resolution(apps, *args): + user_model = apps.get_model('users', 'User') + setting_model = apps.get_model('settings', 'Setting') + preference_model = apps.get_model('users', 'Preference') + s = setting_model.objects.filter(name='TERMINAL_GRAPHICAL_RESOLUTION').first() + if (s and s.value == 'Auto') or not s: + return + + value = s.value + objs = [] + for _id in user_model.objects.values_list('id', flat=True): + objs.append( + preference_model( + name='rdp_resolution', category='luna', + value=value, user_id=_id + ) + ) + preference_model.objects.bulk_create(objs) + + +class Migration(migrations.Migration): + dependencies = [ + ('users', '0042_auto_20230203_1201'), + ] + + operations = [ + migrations.CreateModel( + name='Preference', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('category', models.CharField(max_length=128, verbose_name='Category')), + ('value', models.TextField(blank=True, null=True, verbose_name='Value')), + ('encrypted', models.BooleanField(default=False, verbose_name='Encrypted')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='preferences', + to=settings.AUTH_USER_MODEL, verbose_name='Users')), + ], + options={ + 'verbose_name': 'Preference', + 'db_table': 'users_preference', + 'unique_together': {('name', 'user_id')}, + }, + ), + migrations.RunPython(migrate_secret_key), + migrations.RunPython(migrate_graphical_resolution), + migrations.RemoveField( + model_name='user', + name='secret_key', + ), + ] diff --git a/apps/users/models/__init__.py b/apps/users/models/__init__.py index 80080c292..34e081c5a 100644 --- a/apps/users/models/__init__.py +++ b/apps/users/models/__init__.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- # -from .user import * from .group import * +from .preference import * +from .user import * from .utils import * diff --git a/apps/users/models/preference.py b/apps/users/models/preference.py new file mode 100644 index 000000000..d03d950be --- /dev/null +++ b/apps/users/models/preference.py @@ -0,0 +1,39 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from common.db.utils import Encryptor +from common.utils import get_logger + +logger = get_logger(__name__) + + +class Preference(models.Model): + name = models.CharField(max_length=128, verbose_name=_("Name")) + category = models.CharField(max_length=128, verbose_name=_('Category')) + value = models.TextField(verbose_name=_("Value"), null=True, blank=True) + encrypted = models.BooleanField(default=False, verbose_name=_('Encrypted')) + user = models.ForeignKey( + 'users.User', verbose_name=_("Users"), related_name='preferences', on_delete=models.CASCADE + ) + + def __str__(self): + return f'{self.name}({self.user.username})' + + @classmethod + def encrypt(cls, value): + return Encryptor(value).encrypt() + + @classmethod + def decrypt(cls, value): + return Encryptor(value).decrypt() + + @property + def decrypt_value(self): + if self.encrypted: + return self.decrypt(self.value) + return self.value + + class Meta: + db_table = "users_preference" + verbose_name = _("Preference") + unique_together = [('name', 'user_id')] diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 05c8ea084..49ac4182f 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -819,9 +819,6 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, JSONFilterMixin, Abstract public_key = fields.EncryptTextField( blank=True, null=True, verbose_name=_('Public key') ) - secret_key = fields.EncryptCharField( - max_length=256, blank=True, null=True, verbose_name=_('Secret key') - ) comment = models.TextField( blank=True, null=True, verbose_name=_('Comment') ) @@ -854,6 +851,13 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, JSONFilterMixin, Abstract def __str__(self): return '{0.name}({0.username})'.format(self) + @property + def secret_key(self): + instance = self.preferences.filter(name='secret_key').first() + if not instance: + return + return instance.decrypt_value + @property def receive_backends(self): return self.user_msg_subscription.receive_backends diff --git a/apps/users/serializers/__init__.py b/apps/users/serializers/__init__.py index 3dc95be36..58191b569 100644 --- a/apps/users/serializers/__init__.py +++ b/apps/users/serializers/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # -from .user import * -from .profile import * from .group import * +from .preference import * +from .profile import * from .realtion import * +from .user import * diff --git a/apps/users/serializers/preference/__init__.py b/apps/users/serializers/preference/__init__.py new file mode 100644 index 000000000..2ea5a6e9a --- /dev/null +++ b/apps/users/serializers/preference/__init__.py @@ -0,0 +1,3 @@ +from .koko import * +from .lina import * +from .luna import * diff --git a/apps/users/serializers/preference/koko.py b/apps/users/serializers/preference/koko.py new file mode 100644 index 000000000..5c7ac2e8d --- /dev/null +++ b/apps/users/serializers/preference/koko.py @@ -0,0 +1,15 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from users.const import FileNameConflictResolution + + +class BasicSerializer(serializers.Serializer): + file_name_conflict_resolution = serializers.ChoiceField( + FileNameConflictResolution.choices, default=FileNameConflictResolution.REPLACE, + required=False, label=_('File name conflict resolution') + ) + + +class KokoSerializer(serializers.Serializer): + basic = BasicSerializer(required=False, label=_('Basic')) diff --git a/apps/users/serializers/preference/lina.py b/apps/users/serializers/preference/lina.py new file mode 100644 index 000000000..c31f92805 --- /dev/null +++ b/apps/users/serializers/preference/lina.py @@ -0,0 +1,30 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from common.serializers.fields import EncryptedField + + +class BasicSerializer(serializers.Serializer): + secret_key = EncryptedField( + required=False, max_length=1024, + write_only=True, allow_blank=True, label=_('Secret Key') + ) + secret_key_again = EncryptedField( + required=False, max_length=1024, + write_only=True, allow_blank=True, label=_('Secret Key Again') + ) + + def validate(self, attrs): + secret_key = attrs.pop('secret_key', None) + secret_key_again = attrs.pop('secret_key_again', None) + + if (secret_key or secret_key_again) and secret_key != secret_key_again: + msg = _('The newly set password is inconsistent') + raise serializers.ValidationError({'secret_key_again': msg}) + elif secret_key and secret_key_again: + attrs['secret_key'] = secret_key + return attrs + + +class LinaSerializer(serializers.Serializer): + basic = BasicSerializer(required=False, label=_('Basic')) diff --git a/apps/users/serializers/preference/luna.py b/apps/users/serializers/preference/luna.py new file mode 100644 index 000000000..a6ee02da9 --- /dev/null +++ b/apps/users/serializers/preference/luna.py @@ -0,0 +1,62 @@ +import json + +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from users.const import RDPResolution, KeyboardLayout, RDPClientOption, RemoteApplicationConnectionMethod + + +class MultipleChoiceField(serializers.MultipleChoiceField): + + def to_representation(self, keys): + if isinstance(keys, str): + keys = json.loads(keys) + return keys + + def to_internal_value(self, data): + data = super().to_internal_value(data) + return json.dumps(list(data)) + + +class BasicSerializer(serializers.Serializer): + is_async_asset_tree = serializers.BooleanField( + required=False, default=True, label=_('Async loading of asset tree') + ) + + +class GraphicsSerializer(serializers.Serializer): + rdp_resolution = serializers.ChoiceField( + RDPResolution.choices, default=RDPResolution.AUTO, + required=False, label=_('RDP resolution') + ) + keyboard_layout = serializers.ChoiceField( + KeyboardLayout.choices, default=KeyboardLayout.EN_US_QWERTY, + required=False, label=_('Keyboard layout') + ) + rdp_client_option = MultipleChoiceField( + choices=RDPClientOption.choices, default={RDPClientOption.FULL_SCREEN}, + label=_('RDP client option'), required=False + ) + remote_application_connection_method = serializers.ChoiceField( + RemoteApplicationConnectionMethod.choices, default=RemoteApplicationConnectionMethod.WEB, + required=False, label=_('Remote application connection method') + ) + + +class CommandLineSerializer(serializers.Serializer): + character_terminal_font_size = serializers.IntegerField( + default=14, min_value=1, max_value=9999, required=False, + label=_('Character terminal font size'), + ) + is_backspace_as_ctrl_h = serializers.BooleanField( + required=False, default=False, label=_('Backspace as Ctrl+H') + ) + is_right_click_quickly_paste = serializers.BooleanField( + required=False, default=False, label=_('Right click quickly paste') + ) + + +class LunaSerializer(serializers.Serializer): + basic = BasicSerializer(required=False, label=_('Basic')) + graphics = GraphicsSerializer(required=False, label=_('Graphics')) + command_line = CommandLineSerializer(required=False, label=_('Command line')) diff --git a/apps/users/serializers/profile.py b/apps/users/serializers/profile.py index f03bb133b..51c027054 100644 --- a/apps/users/serializers/profile.py +++ b/apps/users/serializers/profile.py @@ -55,30 +55,6 @@ class UserUpdatePasswordSerializer(serializers.ModelSerializer): return instance -class UserUpdateSecretKeySerializer(serializers.ModelSerializer): - new_secret_key = EncryptedField(required=True, max_length=128) - new_secret_key_again = EncryptedField(required=True, max_length=128) - has_secret_key = serializers.BooleanField(read_only=True, source='secret_key') - - class Meta: - model = User - fields = ['has_secret_key', 'new_secret_key', 'new_secret_key_again'] - - def validate(self, values): - new_secret_key = values.get('new_secret_key', '') - new_secret_key_again = values.get('new_secret_key_again', '') - if new_secret_key != new_secret_key_again: - msg = _('The newly set password is inconsistent') - raise serializers.ValidationError({'new_secret_key_again': msg}) - return values - - def update(self, instance, validated_data): - new_secret_key = self.validated_data.get('new_secret_key') - instance.secret_key = new_secret_key - instance.save() - return instance - - class UserUpdatePublicKeySerializer(serializers.ModelSerializer): public_key_comment = serializers.CharField( source='get_public_key_comment', required=False, read_only=True, max_length=128 diff --git a/apps/users/urls/api_urls.py b/apps/users/urls/api_urls.py index 284482a63..fa44e67e4 100644 --- a/apps/users/urls/api_urls.py +++ b/apps/users/urls/api_urls.py @@ -22,9 +22,9 @@ router.register(r'connection-token', auth_api.ConnectionTokenViewSet, 'connectio urlpatterns = [ path('profile/', api.UserProfileApi.as_view(), name='user-profile'), path('profile/password/', api.UserPasswordApi.as_view(), name='user-password'), - path('profile/secret-key/', api.UserSecretKeyApi.as_view(), name='user-secret-key'), path('profile/public-key/', api.UserPublicKeyApi.as_view(), name='user-public-key'), path('profile/mfa/reset/', api.UserResetMFAApi.as_view(), name='my-mfa-reset'), + path('preference/', api.PreferenceApi.as_view(), name='preference'), path('users//mfa/reset/', api.UserResetMFAApi.as_view(), name='user-reset-mfa'), path('users//password/', api.UserChangePasswordApi.as_view(), name='change-user-password'), path('users//password/reset/', api.UserResetPasswordApi.as_view(), name='user-reset-password'),