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;
+ }
+ }