diff --git a/.github/ISSUE_TEMPLATE/----.md b/.github/ISSUE_TEMPLATE/----.md index 47b19f79e..1d0e97226 100644 --- a/.github/ISSUE_TEMPLATE/----.md +++ b/.github/ISSUE_TEMPLATE/----.md @@ -6,7 +6,6 @@ labels: 类型:需求 assignees: - ibuler - baijiangjie - - wojiushixiaobai --- **请描述您的需求或者改进建议.** diff --git a/.github/ISSUE_TEMPLATE/bug---.md b/.github/ISSUE_TEMPLATE/bug---.md index e4a21adde..491a6ba80 100644 --- a/.github/ISSUE_TEMPLATE/bug---.md +++ b/.github/ISSUE_TEMPLATE/bug---.md @@ -2,11 +2,9 @@ name: Bug 提交 about: 提交产品缺陷帮助我们更好的改进 title: "[Bug] " -labels: 类型:bug +labels: 类型:Bug assignees: - - wojiushixiaobai - baijiangjie - --- **JumpServer 版本( v2.28 之前的版本不再支持 )** diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index b15719590..3be4afd1b 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -4,9 +4,7 @@ about: 提出针对本项目安装部署、使用及其他方面的相关问题 title: "[Question] " labels: 类型:提问 assignees: - - wojiushixiaobai - baijiangjie - --- **请描述您的问题.** diff --git a/apps/accounts/api/account/account.py b/apps/accounts/api/account/account.py index a0d38039b..740ccf041 100644 --- a/apps/accounts/api/account/account.py +++ b/apps/accounts/api/account/account.py @@ -7,10 +7,10 @@ from rest_framework.status import HTTP_200_OK from accounts import serializers from accounts.filters import AccountFilterSet from accounts.models import Account +from accounts.mixins import AccountRecordViewLogMixin from assets.models import Asset, Node -from common.api import ExtraFilterFieldsMixin +from common.api.mixin import ExtraFilterFieldsMixin from common.permissions import UserConfirmation, ConfirmType, IsValidUser -from common.views.mixins import RecordViewLogMixin from orgs.mixins.api import OrgBulkModelViewSet from rbac.permissions import RBACPermission @@ -86,7 +86,7 @@ class AccountViewSet(OrgBulkModelViewSet): return Response(status=HTTP_200_OK) -class AccountSecretsViewSet(RecordViewLogMixin, AccountViewSet): +class AccountSecretsViewSet(AccountRecordViewLogMixin, AccountViewSet): """ 因为可能要导出所有账号,所以单独建立了一个 viewset """ @@ -115,7 +115,7 @@ class AssetAccountBulkCreateApi(CreateAPIView): return Response(data=serializer.data, status=HTTP_200_OK) -class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, RecordViewLogMixin, ListAPIView): +class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, AccountRecordViewLogMixin, ListAPIView): model = Account.history.model serializer_class = serializers.AccountHistorySerializer http_method_names = ['get', 'options'] @@ -143,4 +143,3 @@ class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, RecordViewLogMixin, List return histories histories = histories.exclude(history_id=latest_history.history_id) return histories - diff --git a/apps/accounts/api/account/task.py b/apps/accounts/api/account/task.py index 697824806..16ea84eae 100644 --- a/apps/accounts/api/account/task.py +++ b/apps/accounts/api/account/task.py @@ -19,7 +19,9 @@ class AccountsTaskCreateAPI(CreateAPIView): code = 'accounts.push_account' else: code = 'accounts.verify_account' - return request.user.has_perm(code) + has = request.user.has_perm(code) + if not has: + self.permission_denied(request) def perform_create(self, serializer): data = serializer.validated_data @@ -44,6 +46,6 @@ class AccountsTaskCreateAPI(CreateAPIView): def get_exception_handler(self): def handler(e, context): - return Response({"error": str(e)}, status=400) + return Response({"error": str(e)}, status=401) return handler diff --git a/apps/accounts/api/account/template.py b/apps/accounts/api/account/template.py index 1d2508764..f9c3637f8 100644 --- a/apps/accounts/api/account/template.py +++ b/apps/accounts/api/account/template.py @@ -4,10 +4,10 @@ from rest_framework.response import Response from accounts import serializers from accounts.models import AccountTemplate +from accounts.mixins import AccountRecordViewLogMixin from assets.const import Protocol from common.drf.filters import BaseFilterSet from common.permissions import UserConfirmation, ConfirmType -from common.views.mixins import RecordViewLogMixin from orgs.mixins.api import OrgBulkModelViewSet from rbac.permissions import RBACPermission @@ -55,7 +55,7 @@ class AccountTemplateViewSet(OrgBulkModelViewSet): return Response(data=serializer.data) -class AccountTemplateSecretsViewSet(RecordViewLogMixin, AccountTemplateViewSet): +class AccountTemplateSecretsViewSet(AccountRecordViewLogMixin, AccountTemplateViewSet): serializer_classes = { 'default': serializers.AccountTemplateSecretSerializer, } diff --git a/apps/accounts/automations/change_secret/database/mongodb/main.yml b/apps/accounts/automations/change_secret/database/mongodb/main.yml index 168607289..c51252861 100644 --- a/apps/accounts/automations/change_secret/database/mongodb/main.yml +++ b/apps/accounts/automations/change_secret/database/mongodb/main.yml @@ -11,9 +11,9 @@ login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" login_database: "{{ jms_asset.spec_info.db_name }}" - ssl: "{{ jms_asset.spec_info.use_ssl }}" - ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" - ssl_certfile: "{{ jms_asset.secret_info.client_key }}" + ssl: "{{ jms_asset.spec_info.use_ssl | default('') }}" + ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}" + ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" register: db_info @@ -31,8 +31,8 @@ login_port: "{{ jms_asset.port }}" login_database: "{{ jms_asset.spec_info.db_name }}" ssl: "{{ jms_asset.spec_info.use_ssl }}" - ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" - ssl_certfile: "{{ jms_asset.secret_info.client_key }}" + ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}" + ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" db: "{{ jms_asset.spec_info.db_name }}" @@ -49,7 +49,7 @@ login_port: "{{ jms_asset.port }}" login_database: "{{ jms_asset.spec_info.db_name }}" ssl: "{{ jms_asset.spec_info.use_ssl }}" - ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" - ssl_certfile: "{{ jms_asset.secret_info.client_key }}" + ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}" + ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" diff --git a/apps/accounts/automations/change_secret/database/mysql/main.yml b/apps/accounts/automations/change_secret/database/mysql/main.yml index 2c6965df9..3783dd34b 100644 --- a/apps/accounts/automations/change_secret/database/mysql/main.yml +++ b/apps/accounts/automations/change_secret/database/mysql/main.yml @@ -11,6 +11,10 @@ login_password: "{{ jms_account.secret }}" login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" + check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" + ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}" + client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}" + client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}" filter: version register: db_info @@ -24,6 +28,10 @@ login_password: "{{ jms_account.secret }}" login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" + check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" + ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}" + client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}" + client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}" name: "{{ account.username }}" password: "{{ account.secret }}" host: "%" @@ -37,4 +45,8 @@ login_password: "{{ account.secret }}" login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" + check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" + ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}" + client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}" + client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}" filter: version diff --git a/apps/accounts/automations/change_secret/database/sqlserver/main.yml b/apps/accounts/automations/change_secret/database/sqlserver/main.yml index a1d83f179..5c8c9867e 100644 --- a/apps/accounts/automations/change_secret/database/sqlserver/main.yml +++ b/apps/accounts/automations/change_secret/database/sqlserver/main.yml @@ -40,7 +40,7 @@ login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" name: '{{ jms_asset.spec_info.db_name }}' - script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}'; select @@version" + script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; select @@version" ignore_errors: true when: user_exist.query_results[0] | length != 0 @@ -51,7 +51,7 @@ login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" name: '{{ jms_asset.spec_info.db_name }}' - script: "CREATE LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}'; select @@version" + script: "CREATE LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; CREATE USER {{ account.username }} FOR LOGIN {{ account.username }}; select @@version" ignore_errors: true when: user_exist.query_results[0] | length == 0 diff --git a/apps/accounts/automations/gather_accounts/database/mongodb/main.yml b/apps/accounts/automations/gather_accounts/database/mongodb/main.yml index 452241f6a..87d747ecd 100644 --- a/apps/accounts/automations/gather_accounts/database/mongodb/main.yml +++ b/apps/accounts/automations/gather_accounts/database/mongodb/main.yml @@ -12,8 +12,8 @@ login_port: "{{ jms_asset.port }}" login_database: "{{ jms_asset.spec_info.db_name }}" ssl: "{{ jms_asset.spec_info.use_ssl }}" - ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" - ssl_certfile: "{{ jms_asset.secret_info.client_key }}" + ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}" + ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" filter: users diff --git a/apps/accounts/automations/gather_accounts/database/mysql/main.yml b/apps/accounts/automations/gather_accounts/database/mysql/main.yml index cc934f20f..5888fcb78 100644 --- a/apps/accounts/automations/gather_accounts/database/mysql/main.yml +++ b/apps/accounts/automations/gather_accounts/database/mysql/main.yml @@ -10,6 +10,10 @@ login_password: "{{ jms_account.secret }}" login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" + check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" + ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}" + client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}" + client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}" filter: users register: db_info diff --git a/apps/accounts/automations/push_account/database/mongodb/main.yml b/apps/accounts/automations/push_account/database/mongodb/main.yml index 168607289..8dacc156a 100644 --- a/apps/accounts/automations/push_account/database/mongodb/main.yml +++ b/apps/accounts/automations/push_account/database/mongodb/main.yml @@ -12,8 +12,8 @@ login_port: "{{ jms_asset.port }}" login_database: "{{ jms_asset.spec_info.db_name }}" ssl: "{{ jms_asset.spec_info.use_ssl }}" - ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" - ssl_certfile: "{{ jms_asset.secret_info.client_key }}" + ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}" + ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" register: db_info @@ -31,8 +31,8 @@ login_port: "{{ jms_asset.port }}" login_database: "{{ jms_asset.spec_info.db_name }}" ssl: "{{ jms_asset.spec_info.use_ssl }}" - ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" - ssl_certfile: "{{ jms_asset.secret_info.client_key }}" + ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}" + ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" db: "{{ jms_asset.spec_info.db_name }}" @@ -49,7 +49,7 @@ login_port: "{{ jms_asset.port }}" login_database: "{{ jms_asset.spec_info.db_name }}" ssl: "{{ jms_asset.spec_info.use_ssl }}" - ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" - ssl_certfile: "{{ jms_asset.secret_info.client_key }}" + ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}" + ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" diff --git a/apps/accounts/automations/push_account/database/mysql/main.yml b/apps/accounts/automations/push_account/database/mysql/main.yml index 2c6965df9..3783dd34b 100644 --- a/apps/accounts/automations/push_account/database/mysql/main.yml +++ b/apps/accounts/automations/push_account/database/mysql/main.yml @@ -11,6 +11,10 @@ login_password: "{{ jms_account.secret }}" login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" + check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" + ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}" + client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}" + client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}" filter: version register: db_info @@ -24,6 +28,10 @@ login_password: "{{ jms_account.secret }}" login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" + check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" + ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}" + client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}" + client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}" name: "{{ account.username }}" password: "{{ account.secret }}" host: "%" @@ -37,4 +45,8 @@ login_password: "{{ account.secret }}" login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" + check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" + ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}" + client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}" + client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}" filter: version diff --git a/apps/accounts/automations/push_account/database/postgresql/main.yml b/apps/accounts/automations/push_account/database/postgresql/main.yml index 68fc95324..8b1ecc8ec 100644 --- a/apps/accounts/automations/push_account/database/postgresql/main.yml +++ b/apps/accounts/automations/push_account/database/postgresql/main.yml @@ -31,6 +31,7 @@ role_attr_flags: LOGIN ignore_errors: true when: result is succeeded + register: change_info - name: Verify password community.postgresql.postgresql_ping: @@ -42,3 +43,5 @@ when: - result is succeeded - change_info is succeeded + register: result + failed_when: not result.is_available diff --git a/apps/accounts/automations/push_account/database/sqlserver/main.yml b/apps/accounts/automations/push_account/database/sqlserver/main.yml index 17b64a66a..0a7697ff3 100644 --- a/apps/accounts/automations/push_account/database/sqlserver/main.yml +++ b/apps/accounts/automations/push_account/database/sqlserver/main.yml @@ -40,7 +40,7 @@ login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" name: '{{ jms_asset.spec_info.db_name }}' - script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}'; select @@version" + script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; select @@version" ignore_errors: true when: user_exist.query_results[0] | length != 0 register: change_info @@ -52,7 +52,7 @@ login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" name: '{{ jms_asset.spec_info.db_name }}' - script: "CREATE LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}'; select @@version" + script: "CREATE LOGIN [{{ account.username }}] WITH PASSWORD = '{{ account.secret }}'; CREATE USER [{{ account.username }}] FOR LOGIN [{{ account.username }}]; select @@version" ignore_errors: true when: user_exist.query_results[0] | length == 0 register: change_info diff --git a/apps/accounts/automations/push_account/manager.py b/apps/accounts/automations/push_account/manager.py index 9944f12ed..791a281ca 100644 --- a/apps/accounts/automations/push_account/manager.py +++ b/apps/accounts/automations/push_account/manager.py @@ -80,7 +80,7 @@ class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager): pass def on_runner_failed(self, runner, e): - logger.error("Pust account error: ", e) + logger.error("Pust account error: {}".format(e)) def run(self, *args, **kwargs): if self.secret_type and not self.check_secret(): diff --git a/apps/accounts/automations/verify_account/database/mongodb/main.yml b/apps/accounts/automations/verify_account/database/mongodb/main.yml index 483bfc127..7f6c02a10 100644 --- a/apps/accounts/automations/verify_account/database/mongodb/main.yml +++ b/apps/accounts/automations/verify_account/database/mongodb/main.yml @@ -12,7 +12,7 @@ login_port: "{{ jms_asset.port }}" login_database: "{{ jms_asset.spec_info.db_name }}" ssl: "{{ jms_asset.spec_info.use_ssl }}" - ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" - ssl_certfile: "{{ jms_asset.secret_info.client_key }}" + ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}" + ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" + - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert }}" diff --git a/apps/accounts/automations/verify_account/database/mysql/main.yml b/apps/accounts/automations/verify_account/database/mysql/main.yml index 59c13d98a..4f7181cd1 100644 --- a/apps/accounts/automations/verify_account/database/mysql/main.yml +++ b/apps/accounts/automations/verify_account/database/mysql/main.yml @@ -10,4 +10,8 @@ login_password: "{{ account.secret }}" login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" + check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" + ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}" + client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}" + client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}" filter: version diff --git a/apps/accounts/automations/verify_account/database/postgresql/main.yml b/apps/accounts/automations/verify_account/database/postgresql/main.yml index 077a9ce76..2c97d625f 100644 --- a/apps/accounts/automations/verify_account/database/postgresql/main.yml +++ b/apps/accounts/automations/verify_account/database/postgresql/main.yml @@ -3,7 +3,6 @@ vars: ansible_python_interpreter: /usr/local/bin/python - tasks: - name: Verify account community.postgresql.postgresql_ping: diff --git a/apps/accounts/const/automation.py b/apps/accounts/const/automation.py index cb39703e0..b27080a6f 100644 --- a/apps/accounts/const/automation.py +++ b/apps/accounts/const/automation.py @@ -4,11 +4,13 @@ from django.utils.translation import gettext_lazy as _ from assets.const import Connectivity from common.db.fields import TreeChoices -string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~' DEFAULT_PASSWORD_LENGTH = 30 DEFAULT_PASSWORD_RULES = { 'length': DEFAULT_PASSWORD_LENGTH, - 'symbol_set': string_punctuation + 'uppercase': True, + 'lowercase': True, + 'digit': True, + 'symbol': True, } __all__ = [ @@ -41,8 +43,8 @@ class AutomationTypes(models.TextChoices): class SecretStrategy(models.TextChoices): - custom = 'specific', _('Specific password') - random = 'random', _('Random') + custom = 'specific', _('Specific secret') + random = 'random', _('Random generate') class SSHKeyStrategy(models.TextChoices): diff --git a/apps/accounts/migrations/0003_automation.py b/apps/accounts/migrations/0003_automation.py index a341e18b2..12747a4ab 100644 --- a/apps/accounts/migrations/0003_automation.py +++ b/apps/accounts/migrations/0003_automation.py @@ -113,7 +113,7 @@ class Migration(migrations.Migration): ('comment', models.TextField(blank=True, default='', verbose_name='Comment')), ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), ('old_secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Old secret')), - ('new_secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Secret')), + ('new_secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='New secret')), ('date_started', models.DateTimeField(blank=True, null=True, verbose_name='Date started')), ('date_finished', models.DateTimeField(blank=True, null=True, verbose_name='Date finished')), ('status', models.CharField(default='pending', max_length=16)), diff --git a/apps/accounts/migrations/0015_auto_20230825_1120.py b/apps/accounts/migrations/0015_auto_20230825_1120.py new file mode 100644 index 000000000..083c88634 --- /dev/null +++ b/apps/accounts/migrations/0015_auto_20230825_1120.py @@ -0,0 +1,34 @@ +# Generated by Django 4.1.10 on 2023-08-25 03:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0122_auto_20230803_1553'), + ('accounts', '0014_virtualaccount'), + ] + + operations = [ + migrations.AddField( + model_name='accounttemplate', + name='auto_push', + field=models.BooleanField(default=False, verbose_name='Auto push'), + ), + migrations.AddField( + model_name='accounttemplate', + name='platforms', + field=models.ManyToManyField(related_name='account_templates', to='assets.platform', verbose_name='Platforms', blank=True), + ), + migrations.AddField( + model_name='accounttemplate', + name='push_params', + field=models.JSONField(default=dict, verbose_name='Push params'), + ), + migrations.AddField( + model_name='accounttemplate', + name='secret_strategy', + field=models.CharField(choices=[('specific', 'Specific password'), ('random', 'Random')], default='specific', max_length=16, verbose_name='Secret strategy'), + ), + ] diff --git a/apps/accounts/migrations/0016_accounttemplate_password_rules.py b/apps/accounts/migrations/0016_accounttemplate_password_rules.py new file mode 100644 index 000000000..d036f90a4 --- /dev/null +++ b/apps/accounts/migrations/0016_accounttemplate_password_rules.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.10 on 2023-09-18 08:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0015_auto_20230825_1120'), + ] + + operations = [ + migrations.AddField( + model_name='accounttemplate', + name='password_rules', + field=models.JSONField(default=dict, verbose_name='Password rules'), + ), + ] diff --git a/apps/accounts/mixins.py b/apps/accounts/mixins.py new file mode 100644 index 000000000..f4bacc2ce --- /dev/null +++ b/apps/accounts/mixins.py @@ -0,0 +1,75 @@ +from rest_framework.response import Response +from rest_framework import status +from django.utils import translation +from django.utils.translation import gettext_noop + +from audits.const import ActionChoices +from common.views.mixins import RecordViewLogMixin +from common.utils import i18n_fmt + + +class AccountRecordViewLogMixin(RecordViewLogMixin): + get_object: callable + get_queryset: callable + + @staticmethod + def _filter_params(params): + new_params = {} + need_pop_params = ('format', 'order') + for key, value in params.items(): + if key in need_pop_params: + continue + if isinstance(value, list): + value = list(filter(None, value)) + if value: + new_params[key] = value + return new_params + + def get_resource_display(self, request): + query_params = dict(request.query_params) + params = self._filter_params(query_params) + + spm_filter = params.pop("spm", None) + + if not params and not spm_filter: + display_message = gettext_noop("Export all") + elif spm_filter: + display_message = gettext_noop("Export only selected items") + else: + query = ",".join( + ["%s=%s" % (key, value) for key, value in params.items()] + ) + display_message = i18n_fmt(gettext_noop("Export filtered: %s"), query) + return display_message + + @property + def detail_msg(self): + return i18n_fmt( + gettext_noop('User %s view/export secret'), self.request.user + ) + + def list(self, request, *args, **kwargs): + list_func = getattr(super(), 'list') + if not callable(list_func): + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + response = list_func(request, *args, **kwargs) + with translation.override('en'): + resource_display = self.get_resource_display(request) + ids = [q.id for q in self.get_queryset()] + self.record_logs( + ids, ActionChoices.view, self.detail_msg, resource_display=resource_display + ) + return response + + def retrieve(self, request, *args, **kwargs): + retrieve_func = getattr(super(), 'retrieve') + if not callable(retrieve_func): + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + response = retrieve_func(request, *args, **kwargs) + with translation.override('en'): + resource = self.get_object() + self.record_logs( + [resource.id], ActionChoices.view, self.detail_msg, resource=resource + ) + return response + diff --git a/apps/accounts/models/__init__.py b/apps/accounts/models/__init__.py index 9a3b780c8..5ec98bb68 100644 --- a/apps/accounts/models/__init__.py +++ b/apps/accounts/models/__init__.py @@ -1,5 +1,5 @@ -from .account import * -from .automations import * -from .base import * -from .template import * -from .virtual import * +from .account import * # noqa +from .base import * # noqa +from .automations import * # noqa +from .template import * # noqa +from .virtual import * # noqa diff --git a/apps/accounts/models/automations/base.py b/apps/accounts/models/automations/base.py index 9477a7fdb..79f17614f 100644 --- a/apps/accounts/models/automations/base.py +++ b/apps/accounts/models/automations/base.py @@ -1,12 +1,15 @@ +from django.db import models from django.utils.translation import gettext_lazy as _ +from accounts.const import SSHKeyStrategy +from accounts.models import Account, SecretWithRandomMixin from accounts.tasks import execute_account_automation_task from assets.models.automations import ( BaseAutomation as AssetBaseAutomation, AutomationExecution as AssetAutomationExecution ) -__all__ = ['AccountBaseAutomation', 'AutomationExecution'] +__all__ = ['AccountBaseAutomation', 'AutomationExecution', 'ChangeSecretMixin'] class AccountBaseAutomation(AssetBaseAutomation): @@ -43,3 +46,40 @@ class AutomationExecution(AssetAutomationExecution): from accounts.automations.endpoint import ExecutionManager manager = ExecutionManager(execution=self) return manager.run() + + +class ChangeSecretMixin(SecretWithRandomMixin): + ssh_key_change_strategy = models.CharField( + choices=SSHKeyStrategy.choices, max_length=16, + default=SSHKeyStrategy.add, verbose_name=_('SSH key change strategy') + ) + get_all_assets: callable # get all assets + + class Meta: + abstract = True + + def create_nonlocal_accounts(self, usernames, asset): + pass + + def get_account_ids(self): + usernames = self.accounts + accounts = Account.objects.none() + for asset in self.get_all_assets(): + self.create_nonlocal_accounts(usernames, asset) + accounts = accounts | asset.accounts.all() + account_ids = accounts.filter( + username__in=usernames, secret_type=self.secret_type + ).values_list('id', flat=True) + return [str(_id) for _id in account_ids] + + def to_attr_json(self): + attr_json = super().to_attr_json() + attr_json.update({ + 'secret': self.secret, + 'secret_type': self.secret_type, + 'accounts': self.get_account_ids(), + 'password_rules': self.password_rules, + 'secret_strategy': self.secret_strategy, + 'ssh_key_change_strategy': self.ssh_key_change_strategy, + }) + return attr_json diff --git a/apps/accounts/models/automations/change_secret.py b/apps/accounts/models/automations/change_secret.py index 0efeff049..9c2086142 100644 --- a/apps/accounts/models/automations/change_secret.py +++ b/apps/accounts/models/automations/change_secret.py @@ -2,62 +2,13 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from accounts.const import ( - AutomationTypes, SecretType, SecretStrategy, SSHKeyStrategy + AutomationTypes ) -from accounts.models import Account from common.db import fields from common.db.models import JMSBaseModel -from .base import AccountBaseAutomation +from .base import AccountBaseAutomation, ChangeSecretMixin -__all__ = ['ChangeSecretAutomation', 'ChangeSecretRecord', 'ChangeSecretMixin'] - - -class ChangeSecretMixin(models.Model): - secret_type = models.CharField( - choices=SecretType.choices, max_length=16, - default=SecretType.PASSWORD, verbose_name=_('Secret type') - ) - secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('Secret')) - secret_strategy = models.CharField( - choices=SecretStrategy.choices, max_length=16, - default=SecretStrategy.custom, verbose_name=_('Secret strategy') - ) - password_rules = models.JSONField(default=dict, verbose_name=_('Password rules')) - ssh_key_change_strategy = models.CharField( - choices=SSHKeyStrategy.choices, max_length=16, - default=SSHKeyStrategy.add, verbose_name=_('SSH key change strategy') - ) - - get_all_assets: callable # get all assets - - class Meta: - abstract = True - - def create_nonlocal_accounts(self, usernames, asset): - pass - - def get_account_ids(self): - usernames = self.accounts - accounts = Account.objects.none() - for asset in self.get_all_assets(): - self.create_nonlocal_accounts(usernames, asset) - accounts = accounts | asset.accounts.all() - account_ids = accounts.filter( - username__in=usernames, secret_type=self.secret_type - ).values_list('id', flat=True) - return [str(_id) for _id in account_ids] - - def to_attr_json(self): - attr_json = super().to_attr_json() - attr_json.update({ - 'secret': self.secret, - 'secret_type': self.secret_type, - 'accounts': self.get_account_ids(), - 'password_rules': self.password_rules, - 'secret_strategy': self.secret_strategy, - 'ssh_key_change_strategy': self.ssh_key_change_strategy, - }) - return attr_json +__all__ = ['ChangeSecretAutomation', 'ChangeSecretRecord', ] class ChangeSecretAutomation(ChangeSecretMixin, AccountBaseAutomation): diff --git a/apps/accounts/models/automations/push_account.py b/apps/accounts/models/automations/push_account.py index fe628f4cb..84aa1bb6e 100644 --- a/apps/accounts/models/automations/push_account.py +++ b/apps/accounts/models/automations/push_account.py @@ -17,9 +17,9 @@ class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation): def create_nonlocal_accounts(self, usernames, asset): secret_type = self.secret_type - account_usernames = asset.accounts.filter(secret_type=self.secret_type).values_list( - 'username', flat=True - ) + account_usernames = asset.accounts \ + .filter(secret_type=self.secret_type) \ + .values_list('username', flat=True) create_usernames = set(usernames) - set(account_usernames) create_account_objs = [ Account( @@ -30,9 +30,6 @@ class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation): ] Account.objects.bulk_create(create_account_objs) - def set_period_schedule(self): - pass - @property def dynamic_username(self): return self.username == '@USER' diff --git a/apps/accounts/models/base.py b/apps/accounts/models/base.py index b06012c41..87cc14e2b 100644 --- a/apps/accounts/models/base.py +++ b/apps/accounts/models/base.py @@ -8,12 +8,14 @@ from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ -from accounts.const import SecretType +from accounts.const import SecretType, SecretStrategy +from accounts.models.mixins import VaultModelMixin, VaultManagerMixin, VaultQuerySetMixin +from accounts.utils import SecretGenerator +from common.db import fields from common.utils import ( ssh_key_string_to_obj, ssh_key_gen, get_logger, random_string, lazyproperty, parse_ssh_public_key_str, is_openssh_format_key ) -from accounts.models.mixins import VaultModelMixin, VaultManagerMixin, VaultQuerySetMixin from orgs.mixins.models import JMSOrgBaseModel, OrgManager logger = get_logger(__file__) @@ -29,6 +31,35 @@ class BaseAccountManager(VaultManagerMixin, OrgManager): return self.get_queryset().active() +class SecretWithRandomMixin(models.Model): + secret_type = models.CharField( + choices=SecretType.choices, max_length=16, + default=SecretType.PASSWORD, verbose_name=_('Secret type') + ) + secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('Secret')) + secret_strategy = models.CharField( + choices=SecretStrategy.choices, max_length=16, + default=SecretStrategy.custom, verbose_name=_('Secret strategy') + ) + password_rules = models.JSONField(default=dict, verbose_name=_('Password rules')) + + class Meta: + abstract = True + + @lazyproperty + def secret_generator(self): + return SecretGenerator( + self.secret_strategy, self.secret_type, + self.password_rules, + ) + + def get_secret(self): + if self.secret_strategy == 'random': + return self.secret_generator.get_secret() + else: + return self.secret + + class BaseAccount(VaultModelMixin, JMSOrgBaseModel): name = models.CharField(max_length=128, verbose_name=_("Name")) username = models.CharField(max_length=128, blank=True, verbose_name=_('Username'), db_index=True) diff --git a/apps/accounts/models/template.py b/apps/accounts/models/template.py index adee261e6..0834dcb03 100644 --- a/apps/accounts/models/template.py +++ b/apps/accounts/models/template.py @@ -4,16 +4,22 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from .account import Account -from .base import BaseAccount +from .base import BaseAccount, SecretWithRandomMixin __all__ = ['AccountTemplate', ] -class AccountTemplate(BaseAccount): +class AccountTemplate(BaseAccount, SecretWithRandomMixin): su_from = models.ForeignKey( 'self', related_name='su_to', null=True, on_delete=models.SET_NULL, verbose_name=_("Su from") ) + auto_push = models.BooleanField(default=False, verbose_name=_('Auto push')) + platforms = models.ManyToManyField( + 'assets.Platform', related_name='account_templates', + verbose_name=_('Platforms'), blank=True, + ) + push_params = models.JSONField(default=dict, verbose_name=_('Push params')) class Meta: verbose_name = _('Account template') @@ -25,15 +31,15 @@ class AccountTemplate(BaseAccount): ('change_accounttemplatesecret', _('Can change asset account template secret')), ] + def __str__(self): + return f'{self.name}({self.username})' + @classmethod def get_su_from_account_templates(cls, pk=None): if pk is None: return cls.objects.all() return cls.objects.exclude(Q(id=pk) | Q(su_from_id=pk)) - def __str__(self): - return f'{self.name}({self.username})' - def get_su_from_account(self, asset): su_from = self.su_from if su_from and asset.platform.su_enabled: diff --git a/apps/accounts/serializers/account/account.py b/apps/accounts/serializers/account/account.py index fc5f0b160..0864499b6 100644 --- a/apps/accounts/serializers/account/account.py +++ b/apps/accounts/serializers/account/account.py @@ -2,6 +2,7 @@ import uuid from copy import deepcopy from django.db import IntegrityError +from django.db import transaction from django.db.models import Q from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -73,6 +74,22 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer): name = name + '_' + uuid.uuid4().hex[:4] initial_data['name'] = name + @staticmethod + def get_template_attr_for_account(template): + # Set initial data from template + field_names = [ + 'username', 'secret', 'secret_type', 'privileged', 'is_active' + ] + + attrs = {} + for name in field_names: + value = getattr(template, name, None) + if value is None: + continue + attrs[name] = value + attrs['secret'] = template.get_secret() + return attrs + def from_template_if_need(self, initial_data): if isinstance(initial_data, str): return @@ -89,20 +106,7 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer): raise serializers.ValidationError({'template': 'Template not found'}) self._template = template - # Set initial data from template - ignore_fields = ['id', 'date_created', 'date_updated', 'su_from', 'org_id'] - field_names = [ - field.name for field in template._meta.fields - if field.name not in ignore_fields - ] - field_names = [name if name != '_secret' else 'secret' for name in field_names] - - attrs = {} - for name in field_names: - value = getattr(template, name, None) - if value is None: - continue - attrs[name] = value + attrs = self.get_template_attr_for_account(template) initial_data.update(attrs) initial_data.update({ 'source': Source.TEMPLATE, @@ -114,10 +118,13 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer): asset = get_object_or_404(Asset, pk=asset_id) initial_data['su_from'] = template.get_su_from_account(asset) - @staticmethod - def push_account_if_need(instance, push_now, params, stat): + def push_account_if_need(self, instance, push_now, params, stat): if not push_now or stat not in ['created', 'updated']: return + transaction.on_commit(lambda: self.start_push(instance, params)) + + @staticmethod + def start_push(instance, params): push_accounts_to_assets_task.delay([str(instance.id)], params) def get_validators(self): diff --git a/apps/accounts/serializers/account/template.py b/apps/accounts/serializers/account/template.py index 9642c36b0..149760908 100644 --- a/apps/accounts/serializers/account/template.py +++ b/apps/accounts/serializers/account/template.py @@ -7,10 +7,19 @@ from common.serializers.fields import ObjectRelatedField from .base import BaseAccountSerializer +class PasswordRulesSerializer(serializers.Serializer): + length = serializers.IntegerField(min_value=8, max_value=30, default=16, label=_('Password length')) + lowercase = serializers.BooleanField(default=True, label=_('Lowercase')) + uppercase = serializers.BooleanField(default=True, label=_('Uppercase')) + digit = serializers.BooleanField(default=True, label=_('Digit')) + symbol = serializers.BooleanField(default=True, label=_('Special symbol')) + + class AccountTemplateSerializer(BaseAccountSerializer): is_sync_account = serializers.BooleanField(default=False, write_only=True) _is_sync_account = False + password_rules = PasswordRulesSerializer(required=False, label=_('Password rules')) su_from = ObjectRelatedField( required=False, queryset=AccountTemplate.objects, allow_null=True, allow_empty=True, label=_('Su from'), attrs=('id', 'name', 'username') @@ -18,7 +27,22 @@ class AccountTemplateSerializer(BaseAccountSerializer): class Meta(BaseAccountSerializer.Meta): model = AccountTemplate - fields = BaseAccountSerializer.Meta.fields + ['is_sync_account', 'su_from'] + fields = BaseAccountSerializer.Meta.fields + [ + 'secret_strategy', 'password_rules', + 'auto_push', 'push_params', 'platforms', + 'is_sync_account', 'su_from' + ] + extra_kwargs = { + 'secret_strategy': {'help_text': _('Secret generation strategy for account creation')}, + 'auto_push': {'help_text': _('Whether to automatically push the account to the asset')}, + 'platforms': { + 'help_text': _( + 'Associated platform, you can configure push parameters. ' + 'If not associated, default parameters will be used' + ), + 'required': False + }, + } def sync_accounts_secret(self, instance, diff): if not self._is_sync_account or 'secret' not in diff: diff --git a/apps/accounts/serializers/account/virtual.py b/apps/accounts/serializers/account/virtual.py index d4bd42589..a1d107b20 100644 --- a/apps/accounts/serializers/account/virtual.py +++ b/apps/accounts/serializers/account/virtual.py @@ -19,8 +19,12 @@ class VirtualAccountSerializer(serializers.ModelSerializer): 'comment': {'label': _('Comment')}, 'name': {'label': _('Name')}, 'username': {'label': _('Username')}, - 'secret_from_login': {'help_text': _('Current only support login from AD/LDAP. Secret priority: ' - 'Same account in asset secret > Login secret > Manual input') - }, + 'secret_from_login': { + 'help_text': _( + 'Current only support login from AD/LDAP. Secret priority: ' + 'Same account in asset secret > Login secret > Manual input.
' + 'For security, please set config CACHE_LOGIN_PASSWORD_ENABLED to true' + ) + }, 'alias': {'required': False}, } diff --git a/apps/accounts/serializers/automations/change_secret.py b/apps/accounts/serializers/automations/change_secret.py index da8a1585c..cd7419fdc 100644 --- a/apps/accounts/serializers/automations/change_secret.py +++ b/apps/accounts/serializers/automations/change_secret.py @@ -4,14 +4,13 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from accounts.const import ( - AutomationTypes, DEFAULT_PASSWORD_RULES, - SecretType, SecretStrategy, SSHKeyStrategy + AutomationTypes, SecretType, SecretStrategy, SSHKeyStrategy ) from accounts.models import ( Account, ChangeSecretAutomation, ChangeSecretRecord, AutomationExecution ) -from accounts.serializers import AuthValidateMixin +from accounts.serializers import AuthValidateMixin, PasswordRulesSerializer from assets.models import Asset from common.serializers.fields import LabeledChoiceField, ObjectRelatedField from common.utils import get_logger @@ -42,7 +41,7 @@ class ChangeSecretAutomationSerializer(AuthValidateMixin, BaseAutomationSerializ ssh_key_change_strategy = LabeledChoiceField( choices=SSHKeyStrategy.choices, required=False, label=_('SSH Key strategy') ) - password_rules = serializers.DictField(default=DEFAULT_PASSWORD_RULES) + password_rules = PasswordRulesSerializer(required=False, label=_('Password rules')) secret_type = LabeledChoiceField(choices=get_secret_types(), required=True, label=_('Secret type')) class Meta: diff --git a/apps/accounts/signal_handlers.py b/apps/accounts/signal_handlers.py index 868e85614..d086a049c 100644 --- a/apps/accounts/signal_handlers.py +++ b/apps/accounts/signal_handlers.py @@ -1,9 +1,17 @@ -from django.db.models.signals import pre_save, post_save, post_delete +from collections import defaultdict + +from django.db.models.signals import post_delete +from django.db.models.signals import pre_save, post_save from django.dispatch import receiver +from django.utils.translation import gettext_noop from accounts.backends import vault_client -from common.utils import get_logger +from audits.const import ActivityChoices +from audits.signal_handlers import create_activities +from common.decorators import merge_delay_run +from common.utils import get_logger, i18n_fmt from .models import Account, AccountTemplate +from .tasks.push_account import push_accounts_to_assets_task logger = get_logger(__name__) @@ -16,6 +24,53 @@ def on_account_pre_save(sender, instance, **kwargs): instance.version = instance.history.count() +@merge_delay_run(ttl=5) +def push_accounts_if_need(accounts=()): + from .models import AccountTemplate + + template_accounts = defaultdict(list) + for ac in accounts: + # 再强调一次吧 + if ac.source != 'template': + continue + template_accounts[ac.source_id].append(ac) + + for source_id, accounts in template_accounts.items(): + template = AccountTemplate.objects.filter(id=source_id).first() + if not template or not template.auto_push: + continue + logger.debug("Push accounts to source: %s", source_id) + account_ids = [str(ac.id) for ac in accounts] + task = push_accounts_to_assets_task.delay(account_ids, params=template.push_params) + detail = i18n_fmt( + gettext_noop('Push related accounts to assets: %s, by system'), + len(account_ids) + ) + create_activities([str(template.id)], detail, task.id, ActivityChoices.task, template.org_id) + logger.debug("Push accounts to source: %s, task: %s", source_id, task) + + +def create_accounts_activities(account, action='create'): + if action == 'create': + detail = i18n_fmt(gettext_noop('Add account: %s'), str(account)) + else: + detail = i18n_fmt(gettext_noop('Delete account: %s'), str(account)) + create_activities([account.asset_id], detail, None, ActivityChoices.operate_log, account.org_id) + + +@receiver(post_save, sender=Account) +def on_account_create_by_template(sender, instance, created=False, **kwargs): + if not created or instance.source != 'template': + return + push_accounts_if_need(accounts=(instance,)) + create_accounts_activities(instance, action='create') + + +@receiver(post_delete, sender=Account) +def on_account_delete(sender, instance, **kwargs): + create_accounts_activities(instance, action='delete') + + class VaultSignalHandler(object): """ 处理 Vault 相关的信号 """ diff --git a/apps/accounts/utils.py b/apps/accounts/utils.py index c0e3fc1cd..fb3be63a7 100644 --- a/apps/accounts/utils.py +++ b/apps/accounts/utils.py @@ -1,3 +1,5 @@ +import copy + from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -18,9 +20,19 @@ class SecretGenerator: return private_key def generate_password(self): - length = int(self.password_rules.get('length', 0)) - length = length if length else DEFAULT_PASSWORD_RULES['length'] - return random_string(length, special_char=True) + password_rules = self.password_rules + if not password_rules or not isinstance(password_rules, dict): + password_rules = {} + rules = copy.deepcopy(DEFAULT_PASSWORD_RULES) + rules.update(password_rules) + rules = { + 'length': rules['length'], + 'lower': rules['lowercase'], + 'upper': rules['uppercase'], + 'digit': rules['digit'], + 'special_char': rules['symbol'] + } + return random_string(**rules) def get_secret(self): if self.secret_type == SecretType.SSH_KEY: @@ -39,6 +51,8 @@ def validate_password_for_ansible(password): # Ansible 推送的时候不支持 if '{{' in password: raise serializers.ValidationError(_('Password can not contains `{{` ')) + if '{%' in password: + raise serializers.ValidationError(_('Password can not contains `{%` ')) # Ansible Windows 推送的时候不支持 if "'" in password: raise serializers.ValidationError(_("Password can not contains `'` ")) diff --git a/apps/acls/models/base.py b/apps/acls/models/base.py index 74782b2a3..b8757cea5 100644 --- a/apps/acls/models/base.py +++ b/apps/acls/models/base.py @@ -103,25 +103,27 @@ class UserAssetAccountBaseACL(OrgModelMixin, UserBaseACL): abstract = True @classmethod - def filter_queryset(cls, user=None, asset=None, account=None, account_username=None, **kwargs): + def _get_filter_queryset(cls, user=None, asset=None, account=None, account_username=None, **kwargs): queryset = cls.objects.all() - - if user: - q = cls.users.get_filter_q(user) - queryset = queryset.filter(q) + q = models.Q() if asset: - org_id = asset.org_id - with tmp_to_org(org_id): - q = cls.assets.get_filter_q(asset) - queryset = queryset.filter(q) + q &= cls.assets.get_filter_q(asset) + if user: + q &= cls.users.get_filter_q(user) if account and not account_username: account_username = account.username if account_username: - q = models.Q(accounts__contains=account_username) | \ - models.Q(accounts__contains='*') | \ - models.Q(accounts__contains='@ALL') - queryset = queryset.filter(q) + q &= models.Q(accounts__contains=account_username) | \ + models.Q(accounts__contains='*') | \ + models.Q(accounts__contains='@ALL') if kwargs: - queryset = queryset.filter(**kwargs) + q &= models.Q(**kwargs) + queryset = queryset.filter(q) return queryset.valid().distinct() + + @classmethod + def filter_queryset(cls, asset=None, **kwargs): + org_id = asset.org_id if asset else '' + with tmp_to_org(org_id): + return cls._get_filter_queryset(asset=asset, **kwargs) diff --git a/apps/assets/api/mixin.py b/apps/assets/api/mixin.py index 7d3c1c7a8..6dadbcdaa 100644 --- a/apps/assets/api/mixin.py +++ b/apps/assets/api/mixin.py @@ -42,7 +42,7 @@ class SerializeToTreeNodeMixin: 'name': _name(node), 'title': _name(node), 'pId': node.parent_key, - 'isParent': node.assets_amount > 0, + 'isParent': True, 'open': _open(node), 'meta': { 'data': { diff --git a/apps/assets/api/platform.py b/apps/assets/api/platform.py index a6e34b38b..449b7b8a3 100644 --- a/apps/assets/api/platform.py +++ b/apps/assets/api/platform.py @@ -49,13 +49,19 @@ class AssetPlatformViewSet(JMSModelViewSet): @action(methods=['post'], detail=False, url_path='filter-nodes-assets') def filter_nodes_assets(self, request, *args, **kwargs): node_ids = request.data.get('node_ids', []) - asset_ids = request.data.get('asset_ids', []) - nodes = Node.objects.filter(id__in=node_ids) - node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True) - direct_asset_ids = Asset.objects.filter(id__in=asset_ids).values_list('id', flat=True) - platform_ids = Asset.objects.filter( - id__in=set(list(direct_asset_ids) + list(node_asset_ids)) - ).values_list('platform_id', flat=True) + asset_ids = set(request.data.get('asset_ids', [])) + platform_ids = set(request.data.get('platform_ids', [])) + + if node_ids: + nodes = Node.objects.filter(id__in=node_ids) + node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True) + asset_ids |= set(node_asset_ids) + + if asset_ids: + _platform_ids = Asset.objects \ + .filter(id__in=set(asset_ids)) \ + .values_list('platform_id', flat=True) + platform_ids |= set(_platform_ids) platforms = Platform.objects.filter(id__in=platform_ids) serializer = self.get_serializer(platforms, many=True) return Response(serializer.data) diff --git a/apps/assets/api/tree.py b/apps/assets/api/tree.py index 1970d33ba..762859ca1 100644 --- a/apps/assets/api/tree.py +++ b/apps/assets/api/tree.py @@ -1,14 +1,14 @@ # ~*~ coding: utf-8 ~*~ from django.db.models import Q +from django.utils.translation import gettext_lazy as _ from rest_framework.generics import get_object_or_404 from rest_framework.response import Response -from django.utils.translation import gettext_lazy as _ from assets.locks import NodeAddChildrenLock +from common.exceptions import JMSException from common.tree import TreeNodeSerializer from common.utils import get_logger -from common.exceptions import JMSException from orgs.mixins import generics from orgs.utils import current_org from .mixin import SerializeToTreeNodeMixin @@ -35,8 +35,8 @@ class NodeChildrenApi(generics.ListCreateAPIView): is_initial = False def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) self.instance = self.get_object() - return super().initial(request, *args, **kwargs) def perform_create(self, serializer): with NodeAddChildrenLock(self.instance): diff --git a/apps/assets/automations/base/manager.py b/apps/assets/automations/base/manager.py index 4d35c9121..a035dc700 100644 --- a/apps/assets/automations/base/manager.py +++ b/apps/assets/automations/base/manager.py @@ -175,7 +175,7 @@ class BasePlaybookManager: method = self.method_id_meta_mapper.get(method_id) if not method: logger.error("Method not found: {}".format(method_id)) - return method + return method_playbook_dir_path = method['dir'] sub_playbook_path = os.path.join(sub_playbook_dir, 'project', 'main.yml') shutil.copytree(method_playbook_dir_path, os.path.dirname(sub_playbook_path)) @@ -196,6 +196,11 @@ class BasePlaybookManager: print(msg) runners = [] for platform, assets in assets_group_by_platform.items(): + if not assets: + continue + if not platform.automation or not platform.automation.ansible_enabled: + print(_(" - Platform {} ansible disabled").format(platform.name)) + continue assets_bulked = [assets[i:i + self.bulk_size] for i in range(0, len(assets), self.bulk_size)] for i, _assets in enumerate(assets_bulked, start=1): @@ -204,6 +209,8 @@ class BasePlaybookManager: inventory_path = os.path.join(self.runtime_dir, sub_dir, 'hosts.json') self.generate_inventory(_assets, inventory_path) playbook_path = self.generate_playbook(_assets, platform, playbook_dir) + if not playbook_path: + continue runer = PlaybookRunner( inventory_path, @@ -309,6 +316,7 @@ class BasePlaybookManager: shutil.rmtree(self.runtime_dir) def run(self, *args, **kwargs): + print(">>> 任务准备阶段\n") runners = self.get_runners() if len(runners) > 1: print("### 分次执行任务, 总共 {}\n".format(len(runners))) diff --git a/apps/assets/automations/gather_facts/database/mongodb/main.yml b/apps/assets/automations/gather_facts/database/mongodb/main.yml index 084a27348..bcf2ff5e4 100644 --- a/apps/assets/automations/gather_facts/database/mongodb/main.yml +++ b/apps/assets/automations/gather_facts/database/mongodb/main.yml @@ -12,8 +12,8 @@ login_port: "{{ jms_asset.port }}" login_database: "{{ jms_asset.spec_info.db_name }}" ssl: "{{ jms_asset.spec_info.use_ssl }}" - ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" - ssl_certfile: "{{ jms_asset.secret_info.client_key }}" + ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}" + ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" register: db_info diff --git a/apps/assets/automations/gather_facts/database/mysql/main.yml b/apps/assets/automations/gather_facts/database/mysql/main.yml index 8ba210283..061f8d3f1 100644 --- a/apps/assets/automations/gather_facts/database/mysql/main.yml +++ b/apps/assets/automations/gather_facts/database/mysql/main.yml @@ -10,6 +10,10 @@ login_password: "{{ jms_account.secret }}" login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" + check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" + ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}" + client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}" + client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}" filter: version register: db_info diff --git a/apps/assets/automations/ping/database/mongodb/main.yml b/apps/assets/automations/ping/database/mongodb/main.yml index 23a06c08d..43e0684db 100644 --- a/apps/assets/automations/ping/database/mongodb/main.yml +++ b/apps/assets/automations/ping/database/mongodb/main.yml @@ -12,7 +12,7 @@ login_port: "{{ jms_asset.port }}" login_database: "{{ jms_asset.spec_info.db_name }}" ssl: "{{ jms_asset.spec_info.use_ssl }}" - ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" - ssl_certfile: "{{ jms_asset.secret_info.client_key }}" + ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}" + ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}" connection_options: - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" diff --git a/apps/assets/automations/ping/database/mysql/main.yml b/apps/assets/automations/ping/database/mysql/main.yml index ec7ca9432..3870b720c 100644 --- a/apps/assets/automations/ping/database/mysql/main.yml +++ b/apps/assets/automations/ping/database/mysql/main.yml @@ -10,4 +10,8 @@ login_password: "{{ jms_account.secret }}" login_host: "{{ jms_asset.address }}" login_port: "{{ jms_asset.port }}" + check_hostname: "{{ jms_asset.spec_info.use_ssl and not jms_asset.spec_info.allow_invalid_cert }}" + ca_cert: "{{ jms_asset.secret_info.ca_cert | default(omit) }}" + client_cert: "{{ jms_asset.secret_info.client_cert | default(omit) }}" + client_key: "{{ jms_asset.secret_info.client_key | default(omit) }}" filter: version diff --git a/apps/assets/const/device.py b/apps/assets/const/device.py index 9dfd07ca0..bdf4502e0 100644 --- a/apps/assets/const/device.py +++ b/apps/assets/const/device.py @@ -24,7 +24,7 @@ class DeviceTypes(BaseType): def _get_protocol_constrains(cls) -> dict: return { '*': { - 'choices': ['ssh', 'telnet'] + 'choices': ['ssh', 'telnet', 'sftp'] } } diff --git a/apps/assets/const/protocol.py b/apps/assets/const/protocol.py index 553201237..f1c593522 100644 --- a/apps/assets/const/protocol.py +++ b/apps/assets/const/protocol.py @@ -45,7 +45,13 @@ class Protocol(ChoicesMixin, models.TextChoices): 'sftp_home': { 'type': 'str', 'default': '/tmp', - 'label': _('SFTP home') + 'label': _('SFTP root'), + 'help_text': _( + 'SFTP root directory, Support variable:
' + '- ${ACCOUNT} The connected account username
' + '- ${HOME} The home directory of the connected account
' + '- ${USER} The username of the user' + ) } } }, @@ -154,6 +160,15 @@ class Protocol(ChoicesMixin, models.TextChoices): 'required': True, 'secret_types': ['password'], 'xpack': True, + 'setting': { + 'version': { + 'type': 'choice', + 'choices': [('>=2014', '>= 2014'), ('<2014', '< 2014')], + 'default': '>=2014', + 'label': _('Version'), + 'help_text': _('SQL Server version, Different versions have different connection drivers') + } + } }, cls.clickhouse: { 'port': 9000, diff --git a/apps/assets/migrations/0121_auto_20230725_1458.py b/apps/assets/migrations/0121_auto_20230725_1458.py index 9e50e4337..c5686d950 100644 --- a/apps/assets/migrations/0121_auto_20230725_1458.py +++ b/apps/assets/migrations/0121_auto_20230725_1458.py @@ -49,11 +49,11 @@ def migrate_assets_sftp_protocol(apps, schema_editor): count = 0 print("\nAsset add sftp protocol: ") - asset_ids = asset_cls.objects\ + asset_ids = list(asset_cls.objects\ .filter(platform__in=sftp_platforms)\ .exclude(protocols__name='sftp')\ .distinct()\ - .values_list('id', flat=True) + .values_list('id', flat=True)) while True: _asset_ids = asset_ids[count:count + 1000] if not _asset_ids: diff --git a/apps/assets/migrations/0123_device_automation_ansible_enabled.py b/apps/assets/migrations/0123_device_automation_ansible_enabled.py new file mode 100644 index 000000000..3ef3a4c33 --- /dev/null +++ b/apps/assets/migrations/0123_device_automation_ansible_enabled.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1.10 on 2023-09-13 10:59 + +from django.db import migrations + + +def migrate_device_automation_ansible_enabled(apps, *args): + platform_model = apps.get_model('assets', 'Platform') + automation_model = apps.get_model('assets', 'PlatformAutomation') + ids = platform_model.objects.filter(category='device').values_list('id', flat=True) + automation_model.objects.filter(platform_id__in=ids).update(ansible_enabled=True) + + +class Migration(migrations.Migration): + dependencies = [ + ('assets', '0122_auto_20230803_1553'), + ] + + operations = [ + migrations.RunPython(migrate_device_automation_ansible_enabled) + ] diff --git a/apps/assets/serializers/platform.py b/apps/assets/serializers/platform.py index d42eaab19..7960cba88 100644 --- a/apps/assets/serializers/platform.py +++ b/apps/assets/serializers/platform.py @@ -1,6 +1,7 @@ from django.db.models import QuerySet from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from rest_framework.validators import UniqueValidator from common.serializers import ( WritableNestedModelSerializer, type_field_map, MethodSerializer, @@ -123,6 +124,10 @@ class PlatformSerializer(WritableNestedModelSerializer): ("super", "super 15"), ("super_level", "super level 15") ] + id = serializers.IntegerField( + label='ID', required=False, + validators=[UniqueValidator(queryset=Platform.objects.all())] + ) charset = LabeledChoiceField(choices=Platform.CharsetChoices.choices, label=_("Charset"), default='utf-8') type = LabeledChoiceField(choices=AllTypes.choices(), label=_("Type")) category = LabeledChoiceField(choices=Category.choices, label=_("Category")) @@ -213,7 +218,7 @@ class PlatformSerializer(WritableNestedModelSerializer): def validate_automation(self, automation): automation = automation or {} ansible_enabled = automation.get('ansible_enabled', False) \ - and self.constraints['automation'].get('ansible_enabled', False) + and self.constraints['automation'].get('ansible_enabled', False) automation['ansible_enable'] = ansible_enabled return automation diff --git a/apps/assets/utils/k8s.py b/apps/assets/utils/k8s.py index dbfd20ecf..2b33ded89 100644 --- a/apps/assets/utils/k8s.py +++ b/apps/assets/utils/k8s.py @@ -66,7 +66,7 @@ class KubernetesClient: remote_bind_address = ( urlparse(asset.address).hostname, - urlparse(asset.address).port + urlparse(asset.address).port or 443 ) server = SSHTunnelForwarder( (gateway.address, gateway.port), diff --git a/apps/audits/api.py b/apps/audits/api.py index 56fff6297..72c1d8a99 100644 --- a/apps/audits/api.py +++ b/apps/audits/api.py @@ -1,50 +1,52 @@ # -*- coding: utf-8 -*- # -import os from importlib import import_module from django.conf import settings -from django.shortcuts import get_object_or_404 from django.db.models import F, Value, CharField, Q from django.http import HttpResponse, FileResponse +from django.utils import timezone from django.utils.encoding import escape_uri_path from rest_framework import generics +from rest_framework import status +from rest_framework import viewsets +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.views import APIView -from rest_framework.decorators import action -from common.api import AsyncApiMixin -from common.drf.filters import DatetimeRangeFilter +from common.api import CommonApiMixin +from common.const.http import GET, POST +from common.drf.filters import DatetimeRangeFilterBackend from common.permissions import IsServiceAccount from common.plugins.es import QuerySet as ESQuerySet -from common.utils import is_uuid, get_logger, lazyproperty -from common.const.http import GET, POST from common.storage.ftp_file import FTPFileStorageHandler +from common.utils import is_uuid, get_logger, lazyproperty from orgs.mixins.api import OrgReadonlyModelViewSet, OrgModelViewSet -from orgs.utils import current_org, tmp_to_root_org from orgs.models import Organization +from orgs.utils import current_org, tmp_to_root_org from rbac.permissions import RBACPermission from terminal.models import default_storage from users.models import User from .backends import TYPE_ENGINE_MAPPING from .const import ActivityChoices -from .models import FTPLog, UserLoginLog, OperateLog, PasswordChangeLog, ActivityLog, JobLog +from .models import ( + FTPLog, UserLoginLog, OperateLog, PasswordChangeLog, + ActivityLog, JobLog, UserSession +) from .serializers import ( FTPLogSerializer, UserLoginLogSerializer, JobLogSerializer, OperateLogSerializer, OperateLogActionDetailSerializer, PasswordChangeLogSerializer, ActivityUnionLogSerializer, - FileSerializer + FileSerializer, UserSessionSerializer ) - logger = get_logger(__name__) class JobAuditViewSet(OrgReadonlyModelViewSet): model = JobLog - extra_filter_backends = [DatetimeRangeFilter] + extra_filter_backends = [DatetimeRangeFilterBackend] date_range_filter_fields = [ ('date_start', ('date_from', 'date_to')) ] @@ -57,7 +59,7 @@ class JobAuditViewSet(OrgReadonlyModelViewSet): class FTPLogViewSet(OrgModelViewSet): model = FTPLog serializer_class = FTPLogSerializer - extra_filter_backends = [DatetimeRangeFilter] + extra_filter_backends = [DatetimeRangeFilterBackend] date_range_filter_fields = [ ('date_start', ('date_from', 'date_to')) ] @@ -113,7 +115,7 @@ class FTPLogViewSet(OrgModelViewSet): class UserLoginCommonMixin: model = UserLoginLog serializer_class = UserLoginLogSerializer - extra_filter_backends = [DatetimeRangeFilter] + extra_filter_backends = [DatetimeRangeFilterBackend] date_range_filter_fields = [ ('datetime', ('date_from', 'date_to')) ] @@ -193,7 +195,7 @@ class ResourceActivityAPIView(generics.ListAPIView): class OperateLogViewSet(OrgReadonlyModelViewSet): model = OperateLog serializer_class = OperateLogSerializer - extra_filter_backends = [DatetimeRangeFilter] + extra_filter_backends = [DatetimeRangeFilterBackend] date_range_filter_fields = [ ('datetime', ('date_from', 'date_to')) ] @@ -232,7 +234,7 @@ class OperateLogViewSet(OrgReadonlyModelViewSet): class PasswordChangeLogViewSet(OrgReadonlyModelViewSet): model = PasswordChangeLog serializer_class = PasswordChangeLogSerializer - extra_filter_backends = [DatetimeRangeFilter] + extra_filter_backends = [DatetimeRangeFilterBackend] date_range_filter_fields = [ ('datetime', ('date_from', 'date_to')) ] @@ -248,3 +250,44 @@ class PasswordChangeLogViewSet(OrgReadonlyModelViewSet): user__in=[str(user) for user in users] ) return queryset + + +class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet): + http_method_names = ('get', 'post', 'head', 'options', 'trace') + serializer_class = UserSessionSerializer + filterset_fields = ['id', 'ip', 'city', 'type'] + search_fields = ['id', 'ip', 'city'] + + rbac_perms = { + 'offline': ['users.offline_usersession'] + } + + @property + def org_user_ids(self): + user_ids = current_org.get_members().values_list('id', flat=True) + return user_ids + + def get_queryset(self): + keys = UserSession.get_keys() + queryset = UserSession.objects.filter( + date_expired__gt=timezone.now(), key__in=keys + ) + if current_org.is_root(): + return queryset + user_ids = self.org_user_ids + queryset = queryset.filter(user_id__in=user_ids) + return queryset + + @action(['POST'], detail=False, url_path='offline') + def offline(self, request, *args, **kwargs): + ids = request.data.get('ids', []) + queryset = self.get_queryset().exclude(key=request.session.session_key).filter(id__in=ids) + if not queryset.exists(): + return Response(status=status.HTTP_200_OK) + + keys = queryset.values_list('key', flat=True) + session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore + for key in keys: + session_store_cls(key).delete() + queryset.delete() + return Response(status=status.HTTP_200_OK) diff --git a/apps/audits/const.py b/apps/audits/const.py index a2832ef9d..44d3a556f 100644 --- a/apps/audits/const.py +++ b/apps/audits/const.py @@ -25,6 +25,7 @@ class ActionChoices(TextChoices): delete = "delete", _("Delete") create = "create", _("Create") # Activities action + download = "download", _("Download") connect = "connect", _("Connect") login = "login", _("Login") change_auth = "change_password", _("Change password") diff --git a/apps/audits/migrations/0023_auto_20230906_1322.py b/apps/audits/migrations/0023_auto_20230906_1322.py new file mode 100644 index 000000000..98447b7b3 --- /dev/null +++ b/apps/audits/migrations/0023_auto_20230906_1322.py @@ -0,0 +1,29 @@ +# Generated by Django 4.1.10 on 2023-09-06 05:31 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('audits', '0022_auto_20230605_1555'), + ] + + operations = [ + migrations.AlterField( + model_name='ftplog', + name='date_start', + field=models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Date start'), + ), + migrations.AlterField( + model_name='operatelog', + name='action', + field=models.CharField(choices=[('view', 'View'), ('update', 'Update'), ('delete', 'Delete'), ('create', 'Create'), ('download', 'Download'), ('connect', 'Connect'), ('login', 'Login'), ('change_password', 'Change password')], max_length=16, verbose_name='Action'), + ), + migrations.AlterField( + model_name='userloginlog', + name='datetime', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Date login'), + ), + ] diff --git a/apps/audits/migrations/0024_usersession.py b/apps/audits/migrations/0024_usersession.py new file mode 100644 index 000000000..3cca28f75 --- /dev/null +++ b/apps/audits/migrations/0024_usersession.py @@ -0,0 +1,37 @@ +# Generated by Django 4.1.10 on 2023-09-15 08:58 + +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), + ('audits', '0023_auto_20230906_1322'), + ] + + operations = [ + migrations.CreateModel( + name='UserSession', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('ip', models.GenericIPAddressField(verbose_name='Login IP')), + ('key', models.CharField(max_length=128, verbose_name='Session key')), + ('city', models.CharField(blank=True, max_length=254, null=True, verbose_name='Login city')), + ('user_agent', models.CharField(blank=True, max_length=254, null=True, verbose_name='User agent')), + ('type', models.CharField(choices=[('W', 'Web'), ('T', 'Terminal'), ('U', 'Unknown')], max_length=2, verbose_name='Login type')), + ('backend', models.CharField(default='', max_length=32, verbose_name='Authentication backend')), + ('date_created', models.DateTimeField(blank=True, null=True, verbose_name='Date created')), + ('date_expired', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Date expired')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'User session', + 'ordering': ['-date_created'], + 'permissions': [('offline_usersession', 'Offline ussr session')], + }, + ), + ] diff --git a/apps/audits/models.py b/apps/audits/models.py index 42e712dbd..34ec301f2 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -1,7 +1,9 @@ import os import uuid +from importlib import import_module from django.conf import settings +from django.core.cache import caches from django.db import models from django.db.models import Q from django.utils import timezone @@ -28,7 +30,8 @@ __all__ = [ "ActivityLog", "PasswordChangeLog", "UserLoginLog", - "JobLog" + "JobLog", + "UserSession" ] @@ -57,7 +60,7 @@ class FTPLog(OrgModelMixin): ) filename = models.CharField(max_length=1024, verbose_name=_("Filename")) is_success = models.BooleanField(default=True, verbose_name=_("Success")) - date_start = models.DateTimeField(auto_now_add=True, verbose_name=_("Date start")) + date_start = models.DateTimeField(auto_now_add=True, verbose_name=_("Date start"), db_index=True) has_file = models.BooleanField(default=False, verbose_name=_("File")) session = models.CharField(max_length=36, verbose_name=_("Session"), default=uuid.uuid4) @@ -198,7 +201,7 @@ class UserLoginLog(models.Model): choices=LoginStatusChoices.choices, verbose_name=_("Status"), ) - datetime = models.DateTimeField(default=timezone.now, verbose_name=_("Date login")) + datetime = models.DateTimeField(default=timezone.now, verbose_name=_("Date login"), db_index=True) backend = models.CharField( max_length=32, default="", verbose_name=_("Authentication backend") ) @@ -245,3 +248,44 @@ class UserLoginLog(models.Model): class Meta: ordering = ["-datetime", "username"] verbose_name = _("User login log") + + +class UserSession(models.Model): + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + ip = models.GenericIPAddressField(verbose_name=_("Login IP")) + key = models.CharField(max_length=128, verbose_name=_("Session key")) + city = models.CharField(max_length=254, blank=True, null=True, verbose_name=_("Login city")) + user_agent = models.CharField(max_length=254, blank=True, null=True, verbose_name=_("User agent")) + type = models.CharField(choices=LoginTypeChoices.choices, max_length=2, verbose_name=_("Login type")) + backend = models.CharField(max_length=32, default="", verbose_name=_("Authentication backend")) + date_created = models.DateTimeField(null=True, blank=True, verbose_name=_('Date created')) + date_expired = models.DateTimeField(null=True, blank=True, verbose_name=_("Date expired"), db_index=True) + user = models.ForeignKey( + 'users.User', verbose_name=_('User'), related_name='sessions', on_delete=models.CASCADE + ) + + def __str__(self): + return '%s(%s)' % (self.user, self.ip) + + @property + def backend_display(self): + return gettext(self.backend) + + @staticmethod + def get_keys(): + session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore + cache_key_prefix = session_store_cls.cache_key_prefix + keys = caches[settings.SESSION_CACHE_ALIAS].keys('*') + return [k.replace(cache_key_prefix, '') for k in keys] + + @classmethod + def clear_expired_sessions(cls): + cls.objects.filter(date_expired__lt=timezone.now()).delete() + cls.objects.exclude(key__in=cls.get_keys()).delete() + + class Meta: + ordering = ['-date_created'] + verbose_name = _('User session') + permissions = [ + ('offline_usersession', _('Offline ussr session')), + ] diff --git a/apps/audits/serializers.py b/apps/audits/serializers.py index a90651bc9..829986297 100644 --- a/apps/audits/serializers.py +++ b/apps/audits/serializers.py @@ -4,12 +4,13 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from audits.backends.db import OperateLogStore -from common.serializers.fields import LabeledChoiceField +from common.serializers.fields import LabeledChoiceField, ObjectRelatedField from common.utils import reverse, i18n_trans from common.utils.timezone import as_current_tz from ops.serializers.job import JobExecutionSerializer from orgs.mixins.serializers import BulkOrgResourceModelSerializer from terminal.models import Session +from users.models import User from . import models from .const import ( ActionChoices, OperateChoices, @@ -163,3 +164,27 @@ class ActivityUnionLogSerializer(serializers.Serializer): class FileSerializer(serializers.Serializer): file = serializers.FileField(allow_empty_file=True) + + +class UserSessionSerializer(serializers.ModelSerializer): + type = LabeledChoiceField(choices=LoginTypeChoices.choices, label=_("Type")) + user = ObjectRelatedField(required=False, queryset=User.objects, label=_('User')) + is_current_user_session = serializers.SerializerMethodField() + + class Meta: + model = models.UserSession + fields_mini = ['id'] + fields_small = fields_mini + [ + 'type', 'ip', 'city', 'user_agent', 'user', 'is_current_user_session', + 'backend', 'backend_display', 'date_created', 'date_expired' + ] + fields = fields_small + extra_kwargs = { + "backend_display": {"label": _("Authentication backend")}, + } + + def get_is_current_user_session(self, obj): + request = self.context.get('request') + if not request: + return False + return request.session.session_key == obj.key diff --git a/apps/audits/signal_handlers/login_log.py b/apps/audits/signal_handlers/login_log.py index f74d55e8e..fae32a44b 100644 --- a/apps/audits/signal_handlers/login_log.py +++ b/apps/audits/signal_handlers/login_log.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- # +from datetime import timedelta +from importlib import import_module + from django.conf import settings from django.contrib.auth import BACKEND_SESSION_KEY from django.dispatch import receiver @@ -8,10 +11,13 @@ from django.utils.functional import LazyObject from django.utils.translation import gettext_lazy as _ from rest_framework.request import Request +from audits.models import UserLoginLog from authentication.signals import post_auth_failed, post_auth_success from authentication.utils import check_different_city_login_if_need from common.utils import get_request_ip, get_logger from users.models import User +from ..const import LoginTypeChoices +from ..models import UserSession from ..utils import write_login_log logger = get_logger(__name__) @@ -32,6 +38,7 @@ class AuthBackendLabelMapping(LazyObject): backend_label_mapping[settings.AUTH_BACKEND_FEISHU] = _("FeiShu") backend_label_mapping[settings.AUTH_BACKEND_DINGTALK] = _("DingTalk") backend_label_mapping[settings.AUTH_BACKEND_TEMP_TOKEN] = _("Temporary token") + backend_label_mapping[settings.AUTH_BACKEND_PASSKEY] = _("Passkey") return backend_label_mapping def _setup(self): @@ -74,6 +81,27 @@ def generate_data(username, request, login_type=None): return data +def create_user_session(request, user_id, instance: UserLoginLog): + session_key = request.session.session_key or '-' + session_store_cls = import_module(settings.SESSION_ENGINE).SessionStore + session_store = session_store_cls(session_key=session_key) + ttl = session_store.get_expiry_age() + + online_session_data = { + 'user_id': user_id, + 'ip': instance.ip, + 'key': session_key, + 'city': instance.city, + 'type': instance.type, + 'backend': instance.backend, + 'user_agent': instance.user_agent, + 'date_created': instance.datetime, + 'date_expired': instance.datetime + timedelta(seconds=ttl), + } + user_session = UserSession.objects.create(**online_session_data) + request.session['user_session_id'] = user_session.id + + @receiver(post_auth_success) def on_user_auth_success(sender, user, request, login_type=None, **kwargs): logger.debug('User login success: {}'.format(user.username)) @@ -83,7 +111,11 @@ def on_user_auth_success(sender, user, request, login_type=None, **kwargs): ) request.session['login_time'] = data['datetime'].strftime("%Y-%m-%d %H:%M:%S") data.update({'mfa': int(user.mfa_enabled), 'status': True}) - write_login_log(**data) + instance = write_login_log(**data) + # TODO 目前只记录 web 登录的 session + if instance.type != LoginTypeChoices.web: + return + create_user_session(request, user.id, instance) @receiver(post_auth_failed) diff --git a/apps/audits/urls/api_urls.py b/apps/audits/urls/api_urls.py index 4e1771170..765470afb 100644 --- a/apps/audits/urls/api_urls.py +++ b/apps/audits/urls/api_urls.py @@ -15,6 +15,7 @@ router.register(r'operate-logs', api.OperateLogViewSet, 'operate-log') router.register(r'password-change-logs', api.PasswordChangeLogViewSet, 'password-change-log') router.register(r'job-logs', api.JobAuditViewSet, 'job-log') router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log') +router.register(r'user-sessions', api.UserSessionViewSet, 'user-session') urlpatterns = [ path('activities/', api.ResourceActivityAPIView.as_view(), name='resource-activities'), diff --git a/apps/audits/utils.py b/apps/audits/utils.py index 2c54565d5..44e858098 100644 --- a/apps/audits/utils.py +++ b/apps/audits/utils.py @@ -1,12 +1,12 @@ import copy -from itertools import chain from datetime import datetime +from itertools import chain from django.db import models -from common.utils.timezone import as_current_tz -from common.utils import validate_ip, get_ip_city, get_logger from common.db.fields import RelatedManager +from common.utils import validate_ip, get_ip_city, get_logger +from common.utils.timezone import as_current_tz from .const import DEFAULT_CITY logger = get_logger(__name__) @@ -22,7 +22,7 @@ def write_login_log(*args, **kwargs): else: city = get_ip_city(ip) or DEFAULT_CITY kwargs.update({'ip': ip, 'city': city}) - UserLoginLog.objects.create(**kwargs) + return UserLoginLog.objects.create(**kwargs) def _get_instance_field_value( diff --git a/apps/authentication/api/__init__.py b/apps/authentication/api/__init__.py index 85fda3c1e..17e83813c 100644 --- a/apps/authentication/api/__init__.py +++ b/apps/authentication/api/__init__.py @@ -1,15 +1,15 @@ # -*- coding: utf-8 -*- # -from .connection_token import * -from .token import * -from .mfa import * from .access_key import * from .confirm import * -from .login_confirm import * -from .sso import * -from .wecom import * +from .connection_token import * from .dingtalk import * from .feishu import * +from .login_confirm import * +from .mfa import * from .password import * +from .sso import * from .temp_token import * +from .token import * +from .wecom import * diff --git a/apps/authentication/api/confirm.py b/apps/authentication/api/confirm.py index 0923875a0..3c0e670f0 100644 --- a/apps/authentication/api/confirm.py +++ b/apps/authentication/api/confirm.py @@ -13,7 +13,7 @@ from ..serializers import ConfirmSerializer class ConfirmBindORUNBindOAuth(RetrieveAPIView): - permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),) + permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),) def retrieve(self, request, *args, **kwargs): return Response('ok') diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index e424fe7e3..955ca4028 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -24,6 +24,8 @@ from orgs.mixins.api import RootOrgViewMixin from perms.models import ActionChoices from terminal.connect_methods import NativeClient, ConnectMethodUtil from terminal.models import EndpointRule, Endpoint +from users.const import FileNameConflictResolution +from users.models import Preference from ..models import ConnectionToken, date_expired_default from ..serializers import ( ConnectionTokenSerializer, ConnectionTokenSecretSerializer, @@ -310,9 +312,20 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView self.validate_serializer(serializer) return super().perform_create(serializer) + def _insert_connect_options(self, data, user): + name = 'file_name_conflict_resolution' + connect_options = data.pop('connect_options', {}) + preference = Preference.objects.filter( + name=name, user=user, category='koko' + ).first() + value = preference.value if preference else FileNameConflictResolution.REPLACE + connect_options[name] = value + data['connect_options'] = connect_options + 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') _data = self._validate(user, asset, account_name) @@ -363,7 +376,7 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView def _validate_acl(self, user, asset, account): from acls.models import LoginAssetACL - acls = LoginAssetACL.filter_queryset(user, asset, account) + acls = LoginAssetACL.filter_queryset(user=user, asset=asset, account=account) ip = get_request_ip(self.request) acl = LoginAssetACL.get_match_rule_acls(user, ip, acls) if not acl: diff --git a/apps/authentication/api/dingtalk.py b/apps/authentication/api/dingtalk.py index ee2bdae2e..c8d9f7ce0 100644 --- a/apps/authentication/api/dingtalk.py +++ b/apps/authentication/api/dingtalk.py @@ -1,13 +1,13 @@ -from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.views import APIView -from users.models import User -from common.utils import get_logger -from common.permissions import UserConfirmation -from common.api import RoleUserMixin, RoleAdminMixin -from authentication.const import ConfirmType from authentication import errors +from authentication.const import ConfirmType +from common.api import RoleUserMixin, RoleAdminMixin +from common.permissions import UserConfirmation, IsValidUser +from common.utils import get_logger +from users.models import User logger = get_logger(__file__) @@ -27,7 +27,7 @@ class DingTalkQRUnBindBase(APIView): class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase): - permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),) + permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),) class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase): diff --git a/apps/authentication/api/feishu.py b/apps/authentication/api/feishu.py index 5a6d3721e..148b99e51 100644 --- a/apps/authentication/api/feishu.py +++ b/apps/authentication/api/feishu.py @@ -1,13 +1,13 @@ -from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.views import APIView -from users.models import User -from common.utils import get_logger -from common.permissions import UserConfirmation -from common.api import RoleUserMixin, RoleAdminMixin -from authentication.const import ConfirmType from authentication import errors +from authentication.const import ConfirmType +from common.api import RoleUserMixin, RoleAdminMixin +from common.permissions import UserConfirmation, IsValidUser +from common.utils import get_logger +from users.models import User logger = get_logger(__file__) @@ -27,7 +27,7 @@ class FeiShuQRUnBindBase(APIView): class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase): - permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),) + permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),) class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase): @@ -38,7 +38,7 @@ class FeiShuEventSubscriptionCallback(APIView): """ # https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM """ - permission_classes = () + permission_classes = (IsValidUser,) def post(self, request: Request, *args, **kwargs): return Response(data=request.data) diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index 3436c2eeb..049cd177a 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ +from rest_framework import exceptions from rest_framework.generics import CreateAPIView from rest_framework.permissions import AllowAny from rest_framework.response import Response @@ -13,6 +14,7 @@ from common.utils import get_logger from users.models.user import User from .. import errors from .. import serializers +from ..errors import SessionEmptyError from ..mixins import AuthMixin logger = get_logger(__name__) @@ -56,6 +58,7 @@ class MFASendCodeApi(AuthMixin, CreateAPIView): if not mfa_backend or not mfa_backend.challenge_required: error = _('Current user not support mfa type: {}').format(mfa_type) raise ValidationError({'error': error}) + try: mfa_backend.send_challenge() except Exception as e: @@ -66,6 +69,15 @@ class MFAChallengeVerifyApi(AuthMixin, CreateAPIView): permission_classes = (AllowAny,) serializer_class = serializers.MFAChallengeSerializer + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + try: + user = self.get_user_from_session() + except SessionEmptyError: + user = None + if not user: + raise exceptions.NotAuthenticated() + def perform_create(self, serializer): user = self.get_user_from_session() code = serializer.validated_data.get('code') diff --git a/apps/authentication/api/sso.py b/apps/authentication/api/sso.py index c3b2d688c..0756bad4e 100644 --- a/apps/authentication/api/sso.py +++ b/apps/authentication/api/sso.py @@ -1,26 +1,27 @@ -from uuid import UUID from urllib.parse import urlencode +from uuid import UUID -from django.contrib.auth import login from django.conf import settings +from django.contrib.auth import login from django.http.response import HttpResponseRedirect +from rest_framework import serializers from rest_framework.decorators import action -from rest_framework.response import Response -from rest_framework.request import Request from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response -from common.utils.timezone import utc_now -from common.const.http import POST, GET from common.api import JMSGenericViewSet -from common.serializers import EmptySerializer +from common.const.http import POST, GET from common.permissions import OnlySuperUser +from common.serializers import EmptySerializer from common.utils import reverse +from common.utils.timezone import utc_now from users.models import User -from ..serializers import SSOTokenSerializer -from ..models import SSOToken +from ..errors import SSOAuthClosed from ..filters import AuthKeyQueryDeclaration from ..mixins import AuthMixin -from ..errors import SSOAuthClosed +from ..models import SSOToken +from ..serializers import SSOTokenSerializer NEXT_URL = 'next' AUTH_KEY = 'authkey' @@ -67,6 +68,9 @@ class SSOViewSet(AuthMixin, JMSGenericViewSet): if not next_url or not next_url.startswith('/'): next_url = reverse('index') + if not authkey: + raise serializers.ValidationError("authkey is required") + try: authkey = UUID(authkey) token = SSOToken.objects.get(authkey=authkey, expired=False) diff --git a/apps/authentication/api/wecom.py b/apps/authentication/api/wecom.py index 2779cc9eb..e704d3d4b 100644 --- a/apps/authentication/api/wecom.py +++ b/apps/authentication/api/wecom.py @@ -1,13 +1,13 @@ -from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.views import APIView -from users.models import User -from common.utils import get_logger -from common.permissions import UserConfirmation -from common.api import RoleUserMixin, RoleAdminMixin -from authentication.const import ConfirmType from authentication import errors +from authentication.const import ConfirmType +from common.api import RoleUserMixin, RoleAdminMixin +from common.permissions import UserConfirmation, IsValidUser +from common.utils import get_logger +from users.models import User logger = get_logger(__file__) @@ -27,7 +27,7 @@ class WeComQRUnBindBase(APIView): class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase): - permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),) + permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),) class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase): diff --git a/apps/authentication/backends/passkey/__init__.py b/apps/authentication/backends/passkey/__init__.py new file mode 100644 index 000000000..a0957e5c9 --- /dev/null +++ b/apps/authentication/backends/passkey/__init__.py @@ -0,0 +1 @@ +from .backends import * diff --git a/apps/authentication/backends/passkey/api.py b/apps/authentication/backends/passkey/api.py new file mode 100644 index 000000000..8f5414122 --- /dev/null +++ b/apps/authentication/backends/passkey/api.py @@ -0,0 +1,59 @@ +from django.conf import settings +from django.http import JsonResponse +from django.shortcuts import render +from django.utils.translation import gettext as _ +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.viewsets import ModelViewSet + +from authentication.mixins import AuthMixin +from .fido import register_begin, register_complete, auth_begin, auth_complete +from .models import Passkey +from .serializer import PasskeySerializer +from ...views import FlashMessageMixin + + +class PasskeyViewSet(AuthMixin, FlashMessageMixin, ModelViewSet): + serializer_class = PasskeySerializer + permission_classes = (IsAuthenticated,) + + def get_queryset(self): + return Passkey.objects.filter(user=self.request.user) + + @action(methods=['get', 'post'], detail=False, url_path='register') + def register(self, request): + if request.method == 'GET': + register_data, state = register_begin(request) + return JsonResponse(dict(register_data)) + else: + passkey = register_complete(request) + return JsonResponse({'id': passkey.id.__str__(), 'name': passkey.name}) + + @action(methods=['get'], detail=False, url_path='login', permission_classes=[AllowAny]) + def login(self, request): + return render(request, 'authentication/passkey.html', {}) + + def redirect_to_error(self, error): + self.send_auth_signal(success=False, username='unknown', reason='passkey') + return render(self.request, 'authentication/passkey.html', {'error': error}) + + @action(methods=['get', 'post'], detail=False, url_path='auth', permission_classes=[AllowAny]) + def auth(self, request): + if request.method == 'GET': + auth_data = auth_begin(request) + return JsonResponse(dict(auth_data)) + + try: + user = auth_complete(request) + except ValueError as e: + return self.redirect_to_error(str(e)) + + if not user: + return self.redirect_to_error(_('Auth failed')) + + try: + self.check_oauth2_auth(user, settings.AUTH_BACKEND_PASSKEY) + return self.redirect_to_guard_view() + except Exception as e: + msg = getattr(e, 'msg', '') or str(e) + return self.redirect_to_error(msg) diff --git a/apps/authentication/backends/passkey/backends.py b/apps/authentication/backends/passkey/backends.py new file mode 100644 index 000000000..dc7e1349b --- /dev/null +++ b/apps/authentication/backends/passkey/backends.py @@ -0,0 +1,9 @@ +from django.conf import settings + +from ..base import JMSModelBackend + + +class PasskeyAuthBackend(JMSModelBackend): + @staticmethod + def is_enabled(): + return settings.AUTH_PASSKEY diff --git a/apps/authentication/backends/passkey/fido.py b/apps/authentication/backends/passkey/fido.py new file mode 100644 index 000000000..636b0bb79 --- /dev/null +++ b/apps/authentication/backends/passkey/fido.py @@ -0,0 +1,157 @@ +import json +from urllib.parse import urlparse + +import fido2.features +from django.conf import settings +from django.utils import timezone +from django.utils.translation import gettext as _ +from fido2.server import Fido2Server +from fido2.utils import websafe_decode, websafe_encode +from fido2.webauthn import PublicKeyCredentialRpEntity, AttestedCredentialData, PublicKeyCredentialUserEntity +from rest_framework.serializers import ValidationError +from user_agents.parsers import parse as ua_parse + +from common.utils import get_logger +from .models import Passkey + +logger = get_logger(__name__) + +try: + fido2.features.webauthn_json_mapping.enabled = True +except: + pass + + +def get_current_platform(request): + ua = ua_parse(request.META["HTTP_USER_AGENT"]) + if 'Safari' in ua.browser.family: + return "Apple" + elif 'Chrome' in ua.browser.family and ua.os.family == "Mac OS X": + return "Chrome on Apple" + elif 'Android' in ua.os.family: + return "Google" + elif "Windows" in ua.os.family: + return "Microsoft" + else: + return "Key" + + +def get_server_id_from_request(request, allowed=()): + origin = request.META.get('HTTP_REFERER') + if not origin: + origin = request.get_host() + p = urlparse(origin) + if p.netloc in allowed or p.hostname in allowed: + return p.hostname + else: + return 'localhost' + + +def default_server_id(request): + domains = list(settings.ALLOWED_DOMAINS) + if settings.SITE_URL: + domains.append(urlparse(settings.SITE_URL).hostname) + return get_server_id_from_request(request, allowed=domains) + + +def get_server(request=None): + """Get Server Info from settings and returns a Fido2Server""" + + server_id = settings.FIDO_SERVER_ID or default_server_id(request) + if callable(server_id): + fido_server_id = settings.FIDO_SERVER_ID(request) + elif ',' in server_id: + fido_server_id = get_server_id_from_request(request, allowed=server_id.split(',')) + else: + fido_server_id = server_id + + logger.debug('Fido server id: {}'.format(fido_server_id)) + if callable(settings.FIDO_SERVER_NAME): + fido_server_name = settings.FIDO_SERVER_NAME(request) + else: + fido_server_name = settings.FIDO_SERVER_NAME + + rp = PublicKeyCredentialRpEntity(id=fido_server_id, name=fido_server_name) + return Fido2Server(rp) + + +def get_user_credentials(username): + user_passkeys = Passkey.objects.filter(user__username=username) + return [AttestedCredentialData(websafe_decode(uk.token)) for uk in user_passkeys] + + +def register_begin(request): + server = get_server(request) + user = request.user + user_credentials = get_user_credentials(user.username) + + prefix = request.query_params.get('name', '') + prefix = '(' + prefix + ')' + user_entity = PublicKeyCredentialUserEntity( + id=str(user.id).encode('utf8'), + name=user.username + prefix, + display_name=user.name, + ) + auth_attachment = getattr(settings, 'KEY_ATTACHMENT', None) + data, state = server.register_begin( + user_entity, user_credentials, + authenticator_attachment=auth_attachment, + resident_key_requirement=fido2.webauthn.ResidentKeyRequirement.PREFERRED + ) + request.session['fido2_state'] = state + data = dict(data) + return data, state + + +def register_complete(request): + if not request.session.get("fido2_state"): + raise ValidationError("No state found") + data = request.data + server = get_server(request) + state = request.session.pop("fido2_state") + auth_data = server.register_complete(state, response=data) + encoded = websafe_encode(auth_data.credential_data) + platform = get_current_platform(request) + name = data.pop("key_name", '') or platform + passkey = Passkey.objects.create( + user=request.user, + token=encoded, + name=name, + platform=platform, + credential_id=data.get('id') + ) + return passkey + + +def auth_begin(request): + server = get_server(request) + credentials = [] + + username = None + if request.user.is_authenticated: + username = request.user.username + if username: + credentials = get_user_credentials(username) + auth_data, state = server.authenticate_begin(credentials) + request.session['fido2_state'] = state + return auth_data + + +def auth_complete(request): + server = get_server(request) + data = request.data.get("passkeys") + data = json.loads(data) + cid = data['id'] + + key = Passkey.objects.filter(credential_id=cid, is_active=True).first() + if not key: + raise ValueError(_("This key is not registered")) + + credentials = [AttestedCredentialData(websafe_decode(key.token))] + state = request.session.get('fido2_state') + server.authenticate_complete(state, credentials=credentials, response=data) + + request.session["passkey"] = '{}_{}'.format(key.id, key.name) + key.date_last_used = timezone.now() + key.save(update_fields=['date_last_used']) + return key.user diff --git a/apps/authentication/backends/passkey/models.py b/apps/authentication/backends/passkey/models.py new file mode 100644 index 000000000..0afe55abc --- /dev/null +++ b/apps/authentication/backends/passkey/models.py @@ -0,0 +1,19 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from common.db.models import JMSBaseModel + + +class Passkey(JMSBaseModel): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + name = models.CharField(max_length=255, verbose_name=_("Name")) + is_active = models.BooleanField(default=True, verbose_name=_("Enabled")) + platform = models.CharField(max_length=255, default='', verbose_name=_("Platform")) + added_on = models.DateTimeField(auto_now_add=True, verbose_name=_("Added on")) + date_last_used = models.DateTimeField(null=True, default=None, verbose_name=_("Date last used")) + credential_id = models.CharField(max_length=255, unique=True, null=False, verbose_name=_("Credential ID")) + token = models.CharField(max_length=255, null=False, verbose_name=_("Token")) + + def __str__(self): + return self.name diff --git a/apps/authentication/backends/passkey/serializer.py b/apps/authentication/backends/passkey/serializer.py new file mode 100644 index 000000000..c73386ece --- /dev/null +++ b/apps/authentication/backends/passkey/serializer.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from .models import Passkey + + +class PasskeySerializer(serializers.ModelSerializer): + class Meta: + model = Passkey + fields = [ + 'id', 'name', 'is_active', 'platform', 'created_by', + 'date_last_used', 'date_created', + ] + read_only_fields = list(set(fields) - {'is_active'}) diff --git a/apps/authentication/backends/passkey/urls.py b/apps/authentication/backends/passkey/urls.py new file mode 100644 index 000000000..7a3f5e923 --- /dev/null +++ b/apps/authentication/backends/passkey/urls.py @@ -0,0 +1,9 @@ +from rest_framework.routers import DefaultRouter + +from . import api + +router = DefaultRouter() +router.register('passkeys', api.PasskeyViewSet, 'passkey') + +urlpatterns = [] +urlpatterns += router.urls diff --git a/apps/authentication/backends/saml2/views.py b/apps/authentication/backends/saml2/views.py index e0fa97590..235dd2c38 100644 --- a/apps/authentication/backends/saml2/views.py +++ b/apps/authentication/backends/saml2/views.py @@ -146,7 +146,9 @@ class PrepareRequestMixin: }, 'singleLogoutService': { 'url': f"{sp_host}{reverse('authentication:saml2:saml2-logout')}" - } + }, + 'privateKey': getattr(settings, 'SAML2_SP_KEY_CONTENT', ''), + 'x509cert': getattr(settings, 'SAML2_SP_CERT_CONTENT', ''), } } sp_settings['sp'].update(attrs) diff --git a/apps/authentication/migrations/0022_passkey.py b/apps/authentication/migrations/0022_passkey.py new file mode 100644 index 000000000..322d5cf6f --- /dev/null +++ b/apps/authentication/migrations/0022_passkey.py @@ -0,0 +1,39 @@ +# Generated by Django 4.1.10 on 2023-09-08 08:10 + +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), + ('authentication', '0021_auto_20230713_1459'), + ] + + operations = [ + migrations.CreateModel( + name='Passkey', + fields=[ + ('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')), + ('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated 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')), + ('comment', models.TextField(blank=True, default='', verbose_name='Comment')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('is_active', models.BooleanField(default=True, verbose_name='Enabled')), + ('platform', models.CharField(default='', max_length=255, verbose_name='Platform')), + ('added_on', models.DateTimeField(auto_now_add=True, verbose_name='Added on')), + ('date_last_used', models.DateTimeField(default=None, null=True, verbose_name='Date last used')), + ('credential_id', models.CharField(max_length=255, unique=True, verbose_name='Credential ID')), + ('token', models.CharField(max_length=255, verbose_name='Token')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index f7d3856b0..b301af6c3 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -132,11 +132,11 @@ class CommonMixin: return user user_id = self.request.session.get('user_id') - auth_password = self.request.session.get('auth_password') + auth_ok = self.request.session.get('auth_password') auth_expired_at = self.request.session.get('auth_password_expired_at') auth_expired = auth_expired_at < time.time() if auth_expired_at else False - if not user_id or not auth_password or auth_expired: + if not user_id or not auth_ok or auth_expired: raise errors.SessionEmptyError() user = get_object_or_404(User, pk=user_id) @@ -479,6 +479,7 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost request.session['auto_login'] = auto_login if not auth_backend: auth_backend = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL) + request.session['auth_backend'] = auth_backend def check_oauth2_auth(self, user: User, auth_backend): @@ -511,7 +512,8 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost def clear_auth_mark(self): keys = [ - 'auth_password', 'user_id', 'auth_confirm_required', 'auth_ticket_id', 'auth_acl_id' + 'auth_password', 'user_id', 'auth_confirm_required', + 'auth_ticket_id', 'auth_acl_id' ] for k in keys: self.request.session.pop(k, '') diff --git a/apps/authentication/signal_handlers.py b/apps/authentication/signal_handlers.py index b1f55f689..3ac92411f 100644 --- a/apps/authentication/signal_handlers.py +++ b/apps/authentication/signal_handlers.py @@ -7,6 +7,7 @@ from django.dispatch import receiver from django_cas_ng.signals import cas_user_authenticated from apps.jumpserver.settings.auth import AUTHENTICATION_BACKENDS_THIRD_PARTY +from audits.models import UserSession from .signals import post_auth_success, post_auth_failed, user_auth_failed, user_auth_success @@ -23,6 +24,9 @@ def on_user_auth_login_success(sender, user, request, **kwargs): if not request.session.get("auth_third_party_done") and \ request.session.get('auth_backend') in AUTHENTICATION_BACKENDS_THIRD_PARTY: request.session['auth_third_party_required'] = 1 + + user_session_id = request.session.get('user_session_id') + UserSession.objects.filter(id=user_session_id).update(key=request.session.session_key) # 单点登录,超过了自动退出 if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED: lock_key = 'single_machine_login_' + str(user.id) @@ -30,6 +34,7 @@ def on_user_auth_login_success(sender, user, request, **kwargs): if session_key and session_key != request.session.session_key: session = import_module(settings.SESSION_ENGINE).SessionStore(session_key) session.delete() + UserSession.objects.filter(key=session_key).delete() cache.set(lock_key, request.session.session_key, None) # 标记登录,设置 cookie,前端可以控制刷新, Middleware 会拦截这个生成 cookie diff --git a/apps/authentication/templates/authentication/login.html b/apps/authentication/templates/authentication/login.html index 3b8ae55a9..e19635ca6 100644 --- a/apps/authentication/templates/authentication/login.html +++ b/apps/authentication/templates/authentication/login.html @@ -223,10 +223,55 @@ height: 13px; cursor: pointer; } + + .error-info { + font-size: 16px; + text-align: center; + } + + .mobile-logo { + display: none; + } + + @media (max-width: 768px) { + body { + background-color: #ffffff; + } + + .login-content { + width: 100%; + } + + .left-form-box { + width: 100%; + border-right: none; + } + + .right-image-box { + display: none; + } + + .navbar-top-links { + display: inline; + float: right; + } + + .mobile-logo { + display: block; + padding: 20px 30px 0 30px; + } + } +{% if error_origin %} +
+ {% trans 'Configuration file has problems and cannot be logged in. Please contact the administrator or view latest docs' %}
+ {% trans 'If you are administrator, you can update the config resolve it, set' %}
+ DOMAINS={{ error_origin }} +
+{% endif %}
@@ -234,6 +279,11 @@
+

@@ -294,13 +344,13 @@ {% endif %}