Merge pull request #11636 from jumpserver/dev

v3.7.0
pull/11654/head
Bryan 2023-09-21 17:02:48 +08:00 committed by GitHub
commit 3c54c82ce9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
199 changed files with 5488 additions and 2108 deletions

View File

@ -6,7 +6,6 @@ labels: 类型:需求
assignees: assignees:
- ibuler - ibuler
- baijiangjie - baijiangjie
- wojiushixiaobai
--- ---
**请描述您的需求或者改进建议.** **请描述您的需求或者改进建议.**

View File

@ -2,11 +2,9 @@
name: Bug 提交 name: Bug 提交
about: 提交产品缺陷帮助我们更好的改进 about: 提交产品缺陷帮助我们更好的改进
title: "[Bug] " title: "[Bug] "
labels: 类型:bug labels: 类型:Bug
assignees: assignees:
- wojiushixiaobai
- baijiangjie - baijiangjie
--- ---
**JumpServer 版本( v2.28 之前的版本不再支持 )** **JumpServer 版本( v2.28 之前的版本不再支持 )**

View File

@ -4,9 +4,7 @@ about: 提出针对本项目安装部署、使用及其他方面的相关问题
title: "[Question] " title: "[Question] "
labels: 类型:提问 labels: 类型:提问
assignees: assignees:
- wojiushixiaobai
- baijiangjie - baijiangjie
--- ---
**请描述您的问题.** **请描述您的问题.**

View File

@ -7,10 +7,10 @@ from rest_framework.status import HTTP_200_OK
from accounts import serializers from accounts import serializers
from accounts.filters import AccountFilterSet from accounts.filters import AccountFilterSet
from accounts.models import Account from accounts.models import Account
from accounts.mixins import AccountRecordViewLogMixin
from assets.models import Asset, Node 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.permissions import UserConfirmation, ConfirmType, IsValidUser
from common.views.mixins import RecordViewLogMixin
from orgs.mixins.api import OrgBulkModelViewSet from orgs.mixins.api import OrgBulkModelViewSet
from rbac.permissions import RBACPermission from rbac.permissions import RBACPermission
@ -86,7 +86,7 @@ class AccountViewSet(OrgBulkModelViewSet):
return Response(status=HTTP_200_OK) return Response(status=HTTP_200_OK)
class AccountSecretsViewSet(RecordViewLogMixin, AccountViewSet): class AccountSecretsViewSet(AccountRecordViewLogMixin, AccountViewSet):
""" """
因为可能要导出所有账号所以单独建立了一个 viewset 因为可能要导出所有账号所以单独建立了一个 viewset
""" """
@ -115,7 +115,7 @@ class AssetAccountBulkCreateApi(CreateAPIView):
return Response(data=serializer.data, status=HTTP_200_OK) return Response(data=serializer.data, status=HTTP_200_OK)
class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, RecordViewLogMixin, ListAPIView): class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, AccountRecordViewLogMixin, ListAPIView):
model = Account.history.model model = Account.history.model
serializer_class = serializers.AccountHistorySerializer serializer_class = serializers.AccountHistorySerializer
http_method_names = ['get', 'options'] http_method_names = ['get', 'options']
@ -143,4 +143,3 @@ class AccountHistoriesSecretAPI(ExtraFilterFieldsMixin, RecordViewLogMixin, List
return histories return histories
histories = histories.exclude(history_id=latest_history.history_id) histories = histories.exclude(history_id=latest_history.history_id)
return histories return histories

View File

@ -19,7 +19,9 @@ class AccountsTaskCreateAPI(CreateAPIView):
code = 'accounts.push_account' code = 'accounts.push_account'
else: else:
code = 'accounts.verify_account' 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): def perform_create(self, serializer):
data = serializer.validated_data data = serializer.validated_data
@ -44,6 +46,6 @@ class AccountsTaskCreateAPI(CreateAPIView):
def get_exception_handler(self): def get_exception_handler(self):
def handler(e, context): def handler(e, context):
return Response({"error": str(e)}, status=400) return Response({"error": str(e)}, status=401)
return handler return handler

View File

@ -4,10 +4,10 @@ from rest_framework.response import Response
from accounts import serializers from accounts import serializers
from accounts.models import AccountTemplate from accounts.models import AccountTemplate
from accounts.mixins import AccountRecordViewLogMixin
from assets.const import Protocol from assets.const import Protocol
from common.drf.filters import BaseFilterSet from common.drf.filters import BaseFilterSet
from common.permissions import UserConfirmation, ConfirmType from common.permissions import UserConfirmation, ConfirmType
from common.views.mixins import RecordViewLogMixin
from orgs.mixins.api import OrgBulkModelViewSet from orgs.mixins.api import OrgBulkModelViewSet
from rbac.permissions import RBACPermission from rbac.permissions import RBACPermission
@ -55,7 +55,7 @@ class AccountTemplateViewSet(OrgBulkModelViewSet):
return Response(data=serializer.data) return Response(data=serializer.data)
class AccountTemplateSecretsViewSet(RecordViewLogMixin, AccountTemplateViewSet): class AccountTemplateSecretsViewSet(AccountRecordViewLogMixin, AccountTemplateViewSet):
serializer_classes = { serializer_classes = {
'default': serializers.AccountTemplateSecretSerializer, 'default': serializers.AccountTemplateSecretSerializer,
} }

View File

@ -11,9 +11,9 @@
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}" login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}" ssl: "{{ jms_asset.spec_info.use_ssl | default('') }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}" ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options: connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
register: db_info register: db_info
@ -31,8 +31,8 @@
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}" login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}" ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}" ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options: connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
db: "{{ jms_asset.spec_info.db_name }}" db: "{{ jms_asset.spec_info.db_name }}"
@ -49,7 +49,7 @@
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}" login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}" ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}" ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options: connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"

View File

@ -11,6 +11,10 @@
login_password: "{{ jms_account.secret }}" login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" 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 filter: version
register: db_info register: db_info
@ -24,6 +28,10 @@
login_password: "{{ jms_account.secret }}" login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" 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 }}" name: "{{ account.username }}"
password: "{{ account.secret }}" password: "{{ account.secret }}"
host: "%" host: "%"
@ -37,4 +45,8 @@
login_password: "{{ account.secret }}" login_password: "{{ account.secret }}"
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" 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 filter: version

View File

@ -40,7 +40,7 @@
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
name: '{{ jms_asset.spec_info.db_name }}' 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 ignore_errors: true
when: user_exist.query_results[0] | length != 0 when: user_exist.query_results[0] | length != 0
@ -51,7 +51,7 @@
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
name: '{{ jms_asset.spec_info.db_name }}' 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 ignore_errors: true
when: user_exist.query_results[0] | length == 0 when: user_exist.query_results[0] | length == 0

View File

@ -12,8 +12,8 @@
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}" login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}" ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}" ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options: connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
filter: users filter: users

View File

@ -10,6 +10,10 @@
login_password: "{{ jms_account.secret }}" login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" 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 filter: users
register: db_info register: db_info

View File

@ -12,8 +12,8 @@
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}" login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}" ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}" ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options: connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
register: db_info register: db_info
@ -31,8 +31,8 @@
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}" login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}" ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}" ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options: connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
db: "{{ jms_asset.spec_info.db_name }}" db: "{{ jms_asset.spec_info.db_name }}"
@ -49,7 +49,7 @@
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}" login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}" ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}" ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options: connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"

View File

@ -11,6 +11,10 @@
login_password: "{{ jms_account.secret }}" login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" 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 filter: version
register: db_info register: db_info
@ -24,6 +28,10 @@
login_password: "{{ jms_account.secret }}" login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" 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 }}" name: "{{ account.username }}"
password: "{{ account.secret }}" password: "{{ account.secret }}"
host: "%" host: "%"
@ -37,4 +45,8 @@
login_password: "{{ account.secret }}" login_password: "{{ account.secret }}"
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" 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 filter: version

View File

@ -31,6 +31,7 @@
role_attr_flags: LOGIN role_attr_flags: LOGIN
ignore_errors: true ignore_errors: true
when: result is succeeded when: result is succeeded
register: change_info
- name: Verify password - name: Verify password
community.postgresql.postgresql_ping: community.postgresql.postgresql_ping:
@ -42,3 +43,5 @@
when: when:
- result is succeeded - result is succeeded
- change_info is succeeded - change_info is succeeded
register: result
failed_when: not result.is_available

View File

@ -40,7 +40,7 @@
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
name: '{{ jms_asset.spec_info.db_name }}' 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 ignore_errors: true
when: user_exist.query_results[0] | length != 0 when: user_exist.query_results[0] | length != 0
register: change_info register: change_info
@ -52,7 +52,7 @@
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
name: '{{ jms_asset.spec_info.db_name }}' 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 ignore_errors: true
when: user_exist.query_results[0] | length == 0 when: user_exist.query_results[0] | length == 0
register: change_info register: change_info

View File

@ -80,7 +80,7 @@ class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
pass pass
def on_runner_failed(self, runner, e): 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): def run(self, *args, **kwargs):
if self.secret_type and not self.check_secret(): if self.secret_type and not self.check_secret():

View File

@ -12,7 +12,7 @@
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}" login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}" ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}" ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options: connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert }}"

View File

@ -10,4 +10,8 @@
login_password: "{{ account.secret }}" login_password: "{{ account.secret }}"
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" 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 filter: version

View File

@ -3,7 +3,6 @@
vars: vars:
ansible_python_interpreter: /usr/local/bin/python ansible_python_interpreter: /usr/local/bin/python
tasks: tasks:
- name: Verify account - name: Verify account
community.postgresql.postgresql_ping: community.postgresql.postgresql_ping:

View File

@ -4,11 +4,13 @@ from django.utils.translation import gettext_lazy as _
from assets.const import Connectivity from assets.const import Connectivity
from common.db.fields import TreeChoices from common.db.fields import TreeChoices
string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~'
DEFAULT_PASSWORD_LENGTH = 30 DEFAULT_PASSWORD_LENGTH = 30
DEFAULT_PASSWORD_RULES = { DEFAULT_PASSWORD_RULES = {
'length': DEFAULT_PASSWORD_LENGTH, 'length': DEFAULT_PASSWORD_LENGTH,
'symbol_set': string_punctuation 'uppercase': True,
'lowercase': True,
'digit': True,
'symbol': True,
} }
__all__ = [ __all__ = [
@ -41,8 +43,8 @@ class AutomationTypes(models.TextChoices):
class SecretStrategy(models.TextChoices): class SecretStrategy(models.TextChoices):
custom = 'specific', _('Specific password') custom = 'specific', _('Specific secret')
random = 'random', _('Random') random = 'random', _('Random generate')
class SSHKeyStrategy(models.TextChoices): class SSHKeyStrategy(models.TextChoices):

View File

@ -113,7 +113,7 @@ class Migration(migrations.Migration):
('comment', models.TextField(blank=True, default='', verbose_name='Comment')), ('comment', models.TextField(blank=True, default='', verbose_name='Comment')),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), ('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')), ('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_started', models.DateTimeField(blank=True, null=True, verbose_name='Date started')),
('date_finished', models.DateTimeField(blank=True, null=True, verbose_name='Date finished')), ('date_finished', models.DateTimeField(blank=True, null=True, verbose_name='Date finished')),
('status', models.CharField(default='pending', max_length=16)), ('status', models.CharField(default='pending', max_length=16)),

View File

@ -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'),
),
]

View File

@ -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'),
),
]

75
apps/accounts/mixins.py Normal file
View File

@ -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

View File

@ -1,5 +1,5 @@
from .account import * from .account import * # noqa
from .automations import * from .base import * # noqa
from .base import * from .automations import * # noqa
from .template import * from .template import * # noqa
from .virtual import * from .virtual import * # noqa

View File

@ -1,12 +1,15 @@
from django.db import models
from django.utils.translation import gettext_lazy as _ 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 accounts.tasks import execute_account_automation_task
from assets.models.automations import ( from assets.models.automations import (
BaseAutomation as AssetBaseAutomation, BaseAutomation as AssetBaseAutomation,
AutomationExecution as AssetAutomationExecution AutomationExecution as AssetAutomationExecution
) )
__all__ = ['AccountBaseAutomation', 'AutomationExecution'] __all__ = ['AccountBaseAutomation', 'AutomationExecution', 'ChangeSecretMixin']
class AccountBaseAutomation(AssetBaseAutomation): class AccountBaseAutomation(AssetBaseAutomation):
@ -43,3 +46,40 @@ class AutomationExecution(AssetAutomationExecution):
from accounts.automations.endpoint import ExecutionManager from accounts.automations.endpoint import ExecutionManager
manager = ExecutionManager(execution=self) manager = ExecutionManager(execution=self)
return manager.run() 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

View File

@ -2,62 +2,13 @@ from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from accounts.const import ( from accounts.const import (
AutomationTypes, SecretType, SecretStrategy, SSHKeyStrategy AutomationTypes
) )
from accounts.models import Account
from common.db import fields from common.db import fields
from common.db.models import JMSBaseModel from common.db.models import JMSBaseModel
from .base import AccountBaseAutomation from .base import AccountBaseAutomation, ChangeSecretMixin
__all__ = ['ChangeSecretAutomation', 'ChangeSecretRecord', 'ChangeSecretMixin'] __all__ = ['ChangeSecretAutomation', 'ChangeSecretRecord', ]
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
class ChangeSecretAutomation(ChangeSecretMixin, AccountBaseAutomation): class ChangeSecretAutomation(ChangeSecretMixin, AccountBaseAutomation):

View File

@ -17,9 +17,9 @@ class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation):
def create_nonlocal_accounts(self, usernames, asset): def create_nonlocal_accounts(self, usernames, asset):
secret_type = self.secret_type secret_type = self.secret_type
account_usernames = asset.accounts.filter(secret_type=self.secret_type).values_list( account_usernames = asset.accounts \
'username', flat=True .filter(secret_type=self.secret_type) \
) .values_list('username', flat=True)
create_usernames = set(usernames) - set(account_usernames) create_usernames = set(usernames) - set(account_usernames)
create_account_objs = [ create_account_objs = [
Account( Account(
@ -30,9 +30,6 @@ class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation):
] ]
Account.objects.bulk_create(create_account_objs) Account.objects.bulk_create(create_account_objs)
def set_period_schedule(self):
pass
@property @property
def dynamic_username(self): def dynamic_username(self):
return self.username == '@USER' return self.username == '@USER'

View File

@ -8,12 +8,14 @@ from django.conf import settings
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ 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 ( from common.utils import (
ssh_key_string_to_obj, ssh_key_gen, get_logger, ssh_key_string_to_obj, ssh_key_gen, get_logger,
random_string, lazyproperty, parse_ssh_public_key_str, is_openssh_format_key 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 from orgs.mixins.models import JMSOrgBaseModel, OrgManager
logger = get_logger(__file__) logger = get_logger(__file__)
@ -29,6 +31,35 @@ class BaseAccountManager(VaultManagerMixin, OrgManager):
return self.get_queryset().active() 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): class BaseAccount(VaultModelMixin, JMSOrgBaseModel):
name = models.CharField(max_length=128, verbose_name=_("Name")) name = models.CharField(max_length=128, verbose_name=_("Name"))
username = models.CharField(max_length=128, blank=True, verbose_name=_('Username'), db_index=True) username = models.CharField(max_length=128, blank=True, verbose_name=_('Username'), db_index=True)

View File

@ -4,16 +4,22 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .account import Account from .account import Account
from .base import BaseAccount from .base import BaseAccount, SecretWithRandomMixin
__all__ = ['AccountTemplate', ] __all__ = ['AccountTemplate', ]
class AccountTemplate(BaseAccount): class AccountTemplate(BaseAccount, SecretWithRandomMixin):
su_from = models.ForeignKey( su_from = models.ForeignKey(
'self', related_name='su_to', null=True, 'self', related_name='su_to', null=True,
on_delete=models.SET_NULL, verbose_name=_("Su from") 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: class Meta:
verbose_name = _('Account template') verbose_name = _('Account template')
@ -25,15 +31,15 @@ class AccountTemplate(BaseAccount):
('change_accounttemplatesecret', _('Can change asset account template secret')), ('change_accounttemplatesecret', _('Can change asset account template secret')),
] ]
def __str__(self):
return f'{self.name}({self.username})'
@classmethod @classmethod
def get_su_from_account_templates(cls, pk=None): def get_su_from_account_templates(cls, pk=None):
if pk is None: if pk is None:
return cls.objects.all() return cls.objects.all()
return cls.objects.exclude(Q(id=pk) | Q(su_from_id=pk)) 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): def get_su_from_account(self, asset):
su_from = self.su_from su_from = self.su_from
if su_from and asset.platform.su_enabled: if su_from and asset.platform.su_enabled:

View File

@ -2,6 +2,7 @@ import uuid
from copy import deepcopy from copy import deepcopy
from django.db import IntegrityError from django.db import IntegrityError
from django.db import transaction
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
@ -73,6 +74,22 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer):
name = name + '_' + uuid.uuid4().hex[:4] name = name + '_' + uuid.uuid4().hex[:4]
initial_data['name'] = name 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): def from_template_if_need(self, initial_data):
if isinstance(initial_data, str): if isinstance(initial_data, str):
return return
@ -89,20 +106,7 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer):
raise serializers.ValidationError({'template': 'Template not found'}) raise serializers.ValidationError({'template': 'Template not found'})
self._template = template self._template = template
# Set initial data from template attrs = self.get_template_attr_for_account(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
initial_data.update(attrs) initial_data.update(attrs)
initial_data.update({ initial_data.update({
'source': Source.TEMPLATE, 'source': Source.TEMPLATE,
@ -114,10 +118,13 @@ class AccountCreateUpdateSerializerMixin(serializers.Serializer):
asset = get_object_or_404(Asset, pk=asset_id) asset = get_object_or_404(Asset, pk=asset_id)
initial_data['su_from'] = template.get_su_from_account(asset) initial_data['su_from'] = template.get_su_from_account(asset)
@staticmethod def push_account_if_need(self, instance, push_now, params, stat):
def push_account_if_need(instance, push_now, params, stat):
if not push_now or stat not in ['created', 'updated']: if not push_now or stat not in ['created', 'updated']:
return 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) push_accounts_to_assets_task.delay([str(instance.id)], params)
def get_validators(self): def get_validators(self):

View File

@ -7,10 +7,19 @@ from common.serializers.fields import ObjectRelatedField
from .base import BaseAccountSerializer 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): class AccountTemplateSerializer(BaseAccountSerializer):
is_sync_account = serializers.BooleanField(default=False, write_only=True) is_sync_account = serializers.BooleanField(default=False, write_only=True)
_is_sync_account = False _is_sync_account = False
password_rules = PasswordRulesSerializer(required=False, label=_('Password rules'))
su_from = ObjectRelatedField( su_from = ObjectRelatedField(
required=False, queryset=AccountTemplate.objects, allow_null=True, required=False, queryset=AccountTemplate.objects, allow_null=True,
allow_empty=True, label=_('Su from'), attrs=('id', 'name', 'username') allow_empty=True, label=_('Su from'), attrs=('id', 'name', 'username')
@ -18,7 +27,22 @@ class AccountTemplateSerializer(BaseAccountSerializer):
class Meta(BaseAccountSerializer.Meta): class Meta(BaseAccountSerializer.Meta):
model = AccountTemplate 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): def sync_accounts_secret(self, instance, diff):
if not self._is_sync_account or 'secret' not in diff: if not self._is_sync_account or 'secret' not in diff:

View File

@ -19,8 +19,12 @@ class VirtualAccountSerializer(serializers.ModelSerializer):
'comment': {'label': _('Comment')}, 'comment': {'label': _('Comment')},
'name': {'label': _('Name')}, 'name': {'label': _('Name')},
'username': {'label': _('Username')}, 'username': {'label': _('Username')},
'secret_from_login': {'help_text': _('Current only support login from AD/LDAP. Secret priority: ' 'secret_from_login': {
'Same account in asset secret > Login secret > Manual input') 'help_text': _(
}, 'Current only support login from AD/LDAP. Secret priority: '
'Same account in asset secret > Login secret > Manual input. <br/ >'
'For security, please set config CACHE_LOGIN_PASSWORD_ENABLED to true'
)
},
'alias': {'required': False}, 'alias': {'required': False},
} }

View File

@ -4,14 +4,13 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from accounts.const import ( from accounts.const import (
AutomationTypes, DEFAULT_PASSWORD_RULES, AutomationTypes, SecretType, SecretStrategy, SSHKeyStrategy
SecretType, SecretStrategy, SSHKeyStrategy
) )
from accounts.models import ( from accounts.models import (
Account, ChangeSecretAutomation, Account, ChangeSecretAutomation,
ChangeSecretRecord, AutomationExecution ChangeSecretRecord, AutomationExecution
) )
from accounts.serializers import AuthValidateMixin from accounts.serializers import AuthValidateMixin, PasswordRulesSerializer
from assets.models import Asset from assets.models import Asset
from common.serializers.fields import LabeledChoiceField, ObjectRelatedField from common.serializers.fields import LabeledChoiceField, ObjectRelatedField
from common.utils import get_logger from common.utils import get_logger
@ -42,7 +41,7 @@ class ChangeSecretAutomationSerializer(AuthValidateMixin, BaseAutomationSerializ
ssh_key_change_strategy = LabeledChoiceField( ssh_key_change_strategy = LabeledChoiceField(
choices=SSHKeyStrategy.choices, required=False, label=_('SSH Key strategy') 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')) secret_type = LabeledChoiceField(choices=get_secret_types(), required=True, label=_('Secret type'))
class Meta: class Meta:

View File

@ -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.dispatch import receiver
from django.utils.translation import gettext_noop
from accounts.backends import vault_client 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 .models import Account, AccountTemplate
from .tasks.push_account import push_accounts_to_assets_task
logger = get_logger(__name__) logger = get_logger(__name__)
@ -16,6 +24,53 @@ def on_account_pre_save(sender, instance, **kwargs):
instance.version = instance.history.count() 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): class VaultSignalHandler(object):
""" 处理 Vault 相关的信号 """ """ 处理 Vault 相关的信号 """

View File

@ -1,3 +1,5 @@
import copy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
@ -18,9 +20,19 @@ class SecretGenerator:
return private_key return private_key
def generate_password(self): def generate_password(self):
length = int(self.password_rules.get('length', 0)) password_rules = self.password_rules
length = length if length else DEFAULT_PASSWORD_RULES['length'] if not password_rules or not isinstance(password_rules, dict):
return random_string(length, special_char=True) 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): def get_secret(self):
if self.secret_type == SecretType.SSH_KEY: if self.secret_type == SecretType.SSH_KEY:
@ -39,6 +51,8 @@ def validate_password_for_ansible(password):
# Ansible 推送的时候不支持 # Ansible 推送的时候不支持
if '{{' in password: if '{{' in password:
raise serializers.ValidationError(_('Password can not contains `{{` ')) raise serializers.ValidationError(_('Password can not contains `{{` '))
if '{%' in password:
raise serializers.ValidationError(_('Password can not contains `{%` '))
# Ansible Windows 推送的时候不支持 # Ansible Windows 推送的时候不支持
if "'" in password: if "'" in password:
raise serializers.ValidationError(_("Password can not contains `'` ")) raise serializers.ValidationError(_("Password can not contains `'` "))

View File

@ -103,25 +103,27 @@ class UserAssetAccountBaseACL(OrgModelMixin, UserBaseACL):
abstract = True abstract = True
@classmethod @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() queryset = cls.objects.all()
q = models.Q()
if user:
q = cls.users.get_filter_q(user)
queryset = queryset.filter(q)
if asset: if asset:
org_id = asset.org_id q &= cls.assets.get_filter_q(asset)
with tmp_to_org(org_id): if user:
q = cls.assets.get_filter_q(asset) q &= cls.users.get_filter_q(user)
queryset = queryset.filter(q)
if account and not account_username: if account and not account_username:
account_username = account.username account_username = account.username
if account_username: if account_username:
q = models.Q(accounts__contains=account_username) | \ q &= models.Q(accounts__contains=account_username) | \
models.Q(accounts__contains='*') | \ models.Q(accounts__contains='*') | \
models.Q(accounts__contains='@ALL') models.Q(accounts__contains='@ALL')
queryset = queryset.filter(q)
if kwargs: if kwargs:
queryset = queryset.filter(**kwargs) q &= models.Q(**kwargs)
queryset = queryset.filter(q)
return queryset.valid().distinct() 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)

View File

@ -42,7 +42,7 @@ class SerializeToTreeNodeMixin:
'name': _name(node), 'name': _name(node),
'title': _name(node), 'title': _name(node),
'pId': node.parent_key, 'pId': node.parent_key,
'isParent': node.assets_amount > 0, 'isParent': True,
'open': _open(node), 'open': _open(node),
'meta': { 'meta': {
'data': { 'data': {

View File

@ -49,13 +49,19 @@ class AssetPlatformViewSet(JMSModelViewSet):
@action(methods=['post'], detail=False, url_path='filter-nodes-assets') @action(methods=['post'], detail=False, url_path='filter-nodes-assets')
def filter_nodes_assets(self, request, *args, **kwargs): def filter_nodes_assets(self, request, *args, **kwargs):
node_ids = request.data.get('node_ids', []) node_ids = request.data.get('node_ids', [])
asset_ids = request.data.get('asset_ids', []) asset_ids = set(request.data.get('asset_ids', []))
nodes = Node.objects.filter(id__in=node_ids) platform_ids = set(request.data.get('platform_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) if node_ids:
platform_ids = Asset.objects.filter( nodes = Node.objects.filter(id__in=node_ids)
id__in=set(list(direct_asset_ids) + list(node_asset_ids)) node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True)
).values_list('platform_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) platforms = Platform.objects.filter(id__in=platform_ids)
serializer = self.get_serializer(platforms, many=True) serializer = self.get_serializer(platforms, many=True)
return Response(serializer.data) return Response(serializer.data)

View File

@ -1,14 +1,14 @@
# ~*~ coding: utf-8 ~*~ # ~*~ coding: utf-8 ~*~
from django.db.models import Q 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.generics import get_object_or_404
from rest_framework.response import Response from rest_framework.response import Response
from django.utils.translation import gettext_lazy as _
from assets.locks import NodeAddChildrenLock from assets.locks import NodeAddChildrenLock
from common.exceptions import JMSException
from common.tree import TreeNodeSerializer from common.tree import TreeNodeSerializer
from common.utils import get_logger from common.utils import get_logger
from common.exceptions import JMSException
from orgs.mixins import generics from orgs.mixins import generics
from orgs.utils import current_org from orgs.utils import current_org
from .mixin import SerializeToTreeNodeMixin from .mixin import SerializeToTreeNodeMixin
@ -35,8 +35,8 @@ class NodeChildrenApi(generics.ListCreateAPIView):
is_initial = False is_initial = False
def initial(self, request, *args, **kwargs): def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
self.instance = self.get_object() self.instance = self.get_object()
return super().initial(request, *args, **kwargs)
def perform_create(self, serializer): def perform_create(self, serializer):
with NodeAddChildrenLock(self.instance): with NodeAddChildrenLock(self.instance):

View File

@ -175,7 +175,7 @@ class BasePlaybookManager:
method = self.method_id_meta_mapper.get(method_id) method = self.method_id_meta_mapper.get(method_id)
if not method: if not method:
logger.error("Method not found: {}".format(method_id)) logger.error("Method not found: {}".format(method_id))
return method return
method_playbook_dir_path = method['dir'] method_playbook_dir_path = method['dir']
sub_playbook_path = os.path.join(sub_playbook_dir, 'project', 'main.yml') sub_playbook_path = os.path.join(sub_playbook_dir, 'project', 'main.yml')
shutil.copytree(method_playbook_dir_path, os.path.dirname(sub_playbook_path)) shutil.copytree(method_playbook_dir_path, os.path.dirname(sub_playbook_path))
@ -196,6 +196,11 @@ class BasePlaybookManager:
print(msg) print(msg)
runners = [] runners = []
for platform, assets in assets_group_by_platform.items(): 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)] 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): 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') inventory_path = os.path.join(self.runtime_dir, sub_dir, 'hosts.json')
self.generate_inventory(_assets, inventory_path) self.generate_inventory(_assets, inventory_path)
playbook_path = self.generate_playbook(_assets, platform, playbook_dir) playbook_path = self.generate_playbook(_assets, platform, playbook_dir)
if not playbook_path:
continue
runer = PlaybookRunner( runer = PlaybookRunner(
inventory_path, inventory_path,
@ -309,6 +316,7 @@ class BasePlaybookManager:
shutil.rmtree(self.runtime_dir) shutil.rmtree(self.runtime_dir)
def run(self, *args, **kwargs): def run(self, *args, **kwargs):
print(">>> 任务准备阶段\n")
runners = self.get_runners() runners = self.get_runners()
if len(runners) > 1: if len(runners) > 1:
print("### 分次执行任务, 总共 {}\n".format(len(runners))) print("### 分次执行任务, 总共 {}\n".format(len(runners)))

View File

@ -12,8 +12,8 @@
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}" login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}" ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}" ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options: connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"
register: db_info register: db_info

View File

@ -10,6 +10,10 @@
login_password: "{{ jms_account.secret }}" login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" 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 filter: version
register: db_info register: db_info

View File

@ -12,7 +12,7 @@
login_port: "{{ jms_asset.port }}" login_port: "{{ jms_asset.port }}"
login_database: "{{ jms_asset.spec_info.db_name }}" login_database: "{{ jms_asset.spec_info.db_name }}"
ssl: "{{ jms_asset.spec_info.use_ssl }}" ssl: "{{ jms_asset.spec_info.use_ssl }}"
ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert }}" ssl_ca_certs: "{{ jms_asset.secret_info.ca_cert | default('') }}"
ssl_certfile: "{{ jms_asset.secret_info.client_key }}" ssl_certfile: "{{ jms_asset.secret_info.client_key | default('') }}"
connection_options: connection_options:
- tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}" - tlsAllowInvalidHostnames: "{{ jms_asset.spec_info.allow_invalid_cert}}"

View File

@ -10,4 +10,8 @@
login_password: "{{ jms_account.secret }}" login_password: "{{ jms_account.secret }}"
login_host: "{{ jms_asset.address }}" login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}" 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 filter: version

View File

@ -24,7 +24,7 @@ class DeviceTypes(BaseType):
def _get_protocol_constrains(cls) -> dict: def _get_protocol_constrains(cls) -> dict:
return { return {
'*': { '*': {
'choices': ['ssh', 'telnet'] 'choices': ['ssh', 'telnet', 'sftp']
} }
} }

View File

@ -45,7 +45,13 @@ class Protocol(ChoicesMixin, models.TextChoices):
'sftp_home': { 'sftp_home': {
'type': 'str', 'type': 'str',
'default': '/tmp', 'default': '/tmp',
'label': _('SFTP home') 'label': _('SFTP root'),
'help_text': _(
'SFTP root directory, Support variable: <br>'
'- ${ACCOUNT} The connected account username <br>'
'- ${HOME} The home directory of the connected account <br>'
'- ${USER} The username of the user'
)
} }
} }
}, },
@ -154,6 +160,15 @@ class Protocol(ChoicesMixin, models.TextChoices):
'required': True, 'required': True,
'secret_types': ['password'], 'secret_types': ['password'],
'xpack': True, '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: { cls.clickhouse: {
'port': 9000, 'port': 9000,

View File

@ -49,11 +49,11 @@ def migrate_assets_sftp_protocol(apps, schema_editor):
count = 0 count = 0
print("\nAsset add sftp protocol: ") print("\nAsset add sftp protocol: ")
asset_ids = asset_cls.objects\ asset_ids = list(asset_cls.objects\
.filter(platform__in=sftp_platforms)\ .filter(platform__in=sftp_platforms)\
.exclude(protocols__name='sftp')\ .exclude(protocols__name='sftp')\
.distinct()\ .distinct()\
.values_list('id', flat=True) .values_list('id', flat=True))
while True: while True:
_asset_ids = asset_ids[count:count + 1000] _asset_ids = asset_ids[count:count + 1000]
if not _asset_ids: if not _asset_ids:

View File

@ -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)
]

View File

@ -1,6 +1,7 @@
from django.db.models import QuerySet from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueValidator
from common.serializers import ( from common.serializers import (
WritableNestedModelSerializer, type_field_map, MethodSerializer, WritableNestedModelSerializer, type_field_map, MethodSerializer,
@ -123,6 +124,10 @@ class PlatformSerializer(WritableNestedModelSerializer):
("super", "super 15"), ("super", "super 15"),
("super_level", "super level 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') charset = LabeledChoiceField(choices=Platform.CharsetChoices.choices, label=_("Charset"), default='utf-8')
type = LabeledChoiceField(choices=AllTypes.choices(), label=_("Type")) type = LabeledChoiceField(choices=AllTypes.choices(), label=_("Type"))
category = LabeledChoiceField(choices=Category.choices, label=_("Category")) category = LabeledChoiceField(choices=Category.choices, label=_("Category"))
@ -213,7 +218,7 @@ class PlatformSerializer(WritableNestedModelSerializer):
def validate_automation(self, automation): def validate_automation(self, automation):
automation = automation or {} automation = automation or {}
ansible_enabled = automation.get('ansible_enabled', False) \ 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 automation['ansible_enable'] = ansible_enabled
return automation return automation

View File

@ -66,7 +66,7 @@ class KubernetesClient:
remote_bind_address = ( remote_bind_address = (
urlparse(asset.address).hostname, urlparse(asset.address).hostname,
urlparse(asset.address).port urlparse(asset.address).port or 443
) )
server = SSHTunnelForwarder( server = SSHTunnelForwarder(
(gateway.address, gateway.port), (gateway.address, gateway.port),

View File

@ -1,50 +1,52 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import os
from importlib import import_module from importlib import import_module
from django.conf import settings from django.conf import settings
from django.shortcuts import get_object_or_404
from django.db.models import F, Value, CharField, Q from django.db.models import F, Value, CharField, Q
from django.http import HttpResponse, FileResponse from django.http import HttpResponse, FileResponse
from django.utils import timezone
from django.utils.encoding import escape_uri_path from django.utils.encoding import escape_uri_path
from rest_framework import generics 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.permissions import IsAuthenticated
from rest_framework.response import Response 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.api import CommonApiMixin
from common.drf.filters import DatetimeRangeFilter from common.const.http import GET, POST
from common.drf.filters import DatetimeRangeFilterBackend
from common.permissions import IsServiceAccount from common.permissions import IsServiceAccount
from common.plugins.es import QuerySet as ESQuerySet 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.storage.ftp_file import FTPFileStorageHandler
from common.utils import is_uuid, get_logger, lazyproperty
from orgs.mixins.api import OrgReadonlyModelViewSet, OrgModelViewSet from orgs.mixins.api import OrgReadonlyModelViewSet, OrgModelViewSet
from orgs.utils import current_org, tmp_to_root_org
from orgs.models import Organization from orgs.models import Organization
from orgs.utils import current_org, tmp_to_root_org
from rbac.permissions import RBACPermission from rbac.permissions import RBACPermission
from terminal.models import default_storage from terminal.models import default_storage
from users.models import User from users.models import User
from .backends import TYPE_ENGINE_MAPPING from .backends import TYPE_ENGINE_MAPPING
from .const import ActivityChoices 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 ( from .serializers import (
FTPLogSerializer, UserLoginLogSerializer, JobLogSerializer, FTPLogSerializer, UserLoginLogSerializer, JobLogSerializer,
OperateLogSerializer, OperateLogActionDetailSerializer, OperateLogSerializer, OperateLogActionDetailSerializer,
PasswordChangeLogSerializer, ActivityUnionLogSerializer, PasswordChangeLogSerializer, ActivityUnionLogSerializer,
FileSerializer FileSerializer, UserSessionSerializer
) )
logger = get_logger(__name__) logger = get_logger(__name__)
class JobAuditViewSet(OrgReadonlyModelViewSet): class JobAuditViewSet(OrgReadonlyModelViewSet):
model = JobLog model = JobLog
extra_filter_backends = [DatetimeRangeFilter] extra_filter_backends = [DatetimeRangeFilterBackend]
date_range_filter_fields = [ date_range_filter_fields = [
('date_start', ('date_from', 'date_to')) ('date_start', ('date_from', 'date_to'))
] ]
@ -57,7 +59,7 @@ class JobAuditViewSet(OrgReadonlyModelViewSet):
class FTPLogViewSet(OrgModelViewSet): class FTPLogViewSet(OrgModelViewSet):
model = FTPLog model = FTPLog
serializer_class = FTPLogSerializer serializer_class = FTPLogSerializer
extra_filter_backends = [DatetimeRangeFilter] extra_filter_backends = [DatetimeRangeFilterBackend]
date_range_filter_fields = [ date_range_filter_fields = [
('date_start', ('date_from', 'date_to')) ('date_start', ('date_from', 'date_to'))
] ]
@ -113,7 +115,7 @@ class FTPLogViewSet(OrgModelViewSet):
class UserLoginCommonMixin: class UserLoginCommonMixin:
model = UserLoginLog model = UserLoginLog
serializer_class = UserLoginLogSerializer serializer_class = UserLoginLogSerializer
extra_filter_backends = [DatetimeRangeFilter] extra_filter_backends = [DatetimeRangeFilterBackend]
date_range_filter_fields = [ date_range_filter_fields = [
('datetime', ('date_from', 'date_to')) ('datetime', ('date_from', 'date_to'))
] ]
@ -193,7 +195,7 @@ class ResourceActivityAPIView(generics.ListAPIView):
class OperateLogViewSet(OrgReadonlyModelViewSet): class OperateLogViewSet(OrgReadonlyModelViewSet):
model = OperateLog model = OperateLog
serializer_class = OperateLogSerializer serializer_class = OperateLogSerializer
extra_filter_backends = [DatetimeRangeFilter] extra_filter_backends = [DatetimeRangeFilterBackend]
date_range_filter_fields = [ date_range_filter_fields = [
('datetime', ('date_from', 'date_to')) ('datetime', ('date_from', 'date_to'))
] ]
@ -232,7 +234,7 @@ class OperateLogViewSet(OrgReadonlyModelViewSet):
class PasswordChangeLogViewSet(OrgReadonlyModelViewSet): class PasswordChangeLogViewSet(OrgReadonlyModelViewSet):
model = PasswordChangeLog model = PasswordChangeLog
serializer_class = PasswordChangeLogSerializer serializer_class = PasswordChangeLogSerializer
extra_filter_backends = [DatetimeRangeFilter] extra_filter_backends = [DatetimeRangeFilterBackend]
date_range_filter_fields = [ date_range_filter_fields = [
('datetime', ('date_from', 'date_to')) ('datetime', ('date_from', 'date_to'))
] ]
@ -248,3 +250,44 @@ class PasswordChangeLogViewSet(OrgReadonlyModelViewSet):
user__in=[str(user) for user in users] user__in=[str(user) for user in users]
) )
return queryset 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)

View File

@ -25,6 +25,7 @@ class ActionChoices(TextChoices):
delete = "delete", _("Delete") delete = "delete", _("Delete")
create = "create", _("Create") create = "create", _("Create")
# Activities action # Activities action
download = "download", _("Download")
connect = "connect", _("Connect") connect = "connect", _("Connect")
login = "login", _("Login") login = "login", _("Login")
change_auth = "change_password", _("Change password") change_auth = "change_password", _("Change password")

View File

@ -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'),
),
]

View File

@ -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')],
},
),
]

View File

@ -1,7 +1,9 @@
import os import os
import uuid import uuid
from importlib import import_module
from django.conf import settings from django.conf import settings
from django.core.cache import caches
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
@ -28,7 +30,8 @@ __all__ = [
"ActivityLog", "ActivityLog",
"PasswordChangeLog", "PasswordChangeLog",
"UserLoginLog", "UserLoginLog",
"JobLog" "JobLog",
"UserSession"
] ]
@ -57,7 +60,7 @@ class FTPLog(OrgModelMixin):
) )
filename = models.CharField(max_length=1024, verbose_name=_("Filename")) filename = models.CharField(max_length=1024, verbose_name=_("Filename"))
is_success = models.BooleanField(default=True, verbose_name=_("Success")) 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")) has_file = models.BooleanField(default=False, verbose_name=_("File"))
session = models.CharField(max_length=36, verbose_name=_("Session"), default=uuid.uuid4) session = models.CharField(max_length=36, verbose_name=_("Session"), default=uuid.uuid4)
@ -198,7 +201,7 @@ class UserLoginLog(models.Model):
choices=LoginStatusChoices.choices, choices=LoginStatusChoices.choices,
verbose_name=_("Status"), 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( backend = models.CharField(
max_length=32, default="", verbose_name=_("Authentication backend") max_length=32, default="", verbose_name=_("Authentication backend")
) )
@ -245,3 +248,44 @@ class UserLoginLog(models.Model):
class Meta: class Meta:
ordering = ["-datetime", "username"] ordering = ["-datetime", "username"]
verbose_name = _("User login log") 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')),
]

View File

@ -4,12 +4,13 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from audits.backends.db import OperateLogStore 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 import reverse, i18n_trans
from common.utils.timezone import as_current_tz from common.utils.timezone import as_current_tz
from ops.serializers.job import JobExecutionSerializer from ops.serializers.job import JobExecutionSerializer
from orgs.mixins.serializers import BulkOrgResourceModelSerializer from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from terminal.models import Session from terminal.models import Session
from users.models import User
from . import models from . import models
from .const import ( from .const import (
ActionChoices, OperateChoices, ActionChoices, OperateChoices,
@ -163,3 +164,27 @@ class ActivityUnionLogSerializer(serializers.Serializer):
class FileSerializer(serializers.Serializer): class FileSerializer(serializers.Serializer):
file = serializers.FileField(allow_empty_file=True) 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

View File

@ -1,5 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from datetime import timedelta
from importlib import import_module
from django.conf import settings from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY from django.contrib.auth import BACKEND_SESSION_KEY
from django.dispatch import receiver from django.dispatch import receiver
@ -8,10 +11,13 @@ from django.utils.functional import LazyObject
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.request import Request from rest_framework.request import Request
from audits.models import UserLoginLog
from authentication.signals import post_auth_failed, post_auth_success from authentication.signals import post_auth_failed, post_auth_success
from authentication.utils import check_different_city_login_if_need from authentication.utils import check_different_city_login_if_need
from common.utils import get_request_ip, get_logger from common.utils import get_request_ip, get_logger
from users.models import User from users.models import User
from ..const import LoginTypeChoices
from ..models import UserSession
from ..utils import write_login_log from ..utils import write_login_log
logger = get_logger(__name__) 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_FEISHU] = _("FeiShu")
backend_label_mapping[settings.AUTH_BACKEND_DINGTALK] = _("DingTalk") backend_label_mapping[settings.AUTH_BACKEND_DINGTALK] = _("DingTalk")
backend_label_mapping[settings.AUTH_BACKEND_TEMP_TOKEN] = _("Temporary token") backend_label_mapping[settings.AUTH_BACKEND_TEMP_TOKEN] = _("Temporary token")
backend_label_mapping[settings.AUTH_BACKEND_PASSKEY] = _("Passkey")
return backend_label_mapping return backend_label_mapping
def _setup(self): def _setup(self):
@ -74,6 +81,27 @@ def generate_data(username, request, login_type=None):
return data 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) @receiver(post_auth_success)
def on_user_auth_success(sender, user, request, login_type=None, **kwargs): def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
logger.debug('User login success: {}'.format(user.username)) 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") request.session['login_time'] = data['datetime'].strftime("%Y-%m-%d %H:%M:%S")
data.update({'mfa': int(user.mfa_enabled), 'status': True}) 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) @receiver(post_auth_failed)

View File

@ -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'password-change-logs', api.PasswordChangeLogViewSet, 'password-change-log')
router.register(r'job-logs', api.JobAuditViewSet, 'job-log') router.register(r'job-logs', api.JobAuditViewSet, 'job-log')
router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log') router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log')
router.register(r'user-sessions', api.UserSessionViewSet, 'user-session')
urlpatterns = [ urlpatterns = [
path('activities/', api.ResourceActivityAPIView.as_view(), name='resource-activities'), path('activities/', api.ResourceActivityAPIView.as_view(), name='resource-activities'),

View File

@ -1,12 +1,12 @@
import copy import copy
from itertools import chain
from datetime import datetime from datetime import datetime
from itertools import chain
from django.db import models 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.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 from .const import DEFAULT_CITY
logger = get_logger(__name__) logger = get_logger(__name__)
@ -22,7 +22,7 @@ def write_login_log(*args, **kwargs):
else: else:
city = get_ip_city(ip) or DEFAULT_CITY city = get_ip_city(ip) or DEFAULT_CITY
kwargs.update({'ip': ip, 'city': city}) kwargs.update({'ip': ip, 'city': city})
UserLoginLog.objects.create(**kwargs) return UserLoginLog.objects.create(**kwargs)
def _get_instance_field_value( def _get_instance_field_value(

View File

@ -1,15 +1,15 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from .connection_token import *
from .token import *
from .mfa import *
from .access_key import * from .access_key import *
from .confirm import * from .confirm import *
from .login_confirm import * from .connection_token import *
from .sso import *
from .wecom import *
from .dingtalk import * from .dingtalk import *
from .feishu import * from .feishu import *
from .login_confirm import *
from .mfa import *
from .password import * from .password import *
from .sso import *
from .temp_token import * from .temp_token import *
from .token import *
from .wecom import *

View File

@ -13,7 +13,7 @@ from ..serializers import ConfirmSerializer
class ConfirmBindORUNBindOAuth(RetrieveAPIView): class ConfirmBindORUNBindOAuth(RetrieveAPIView):
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),) permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
return Response('ok') return Response('ok')

View File

@ -24,6 +24,8 @@ from orgs.mixins.api import RootOrgViewMixin
from perms.models import ActionChoices from perms.models import ActionChoices
from terminal.connect_methods import NativeClient, ConnectMethodUtil from terminal.connect_methods import NativeClient, ConnectMethodUtil
from terminal.models import EndpointRule, Endpoint from terminal.models import EndpointRule, Endpoint
from users.const import FileNameConflictResolution
from users.models import Preference
from ..models import ConnectionToken, date_expired_default from ..models import ConnectionToken, date_expired_default
from ..serializers import ( from ..serializers import (
ConnectionTokenSerializer, ConnectionTokenSecretSerializer, ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
@ -310,9 +312,20 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
self.validate_serializer(serializer) self.validate_serializer(serializer)
return super().perform_create(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): def validate_serializer(self, serializer):
data = serializer.validated_data data = serializer.validated_data
user = self.get_user(serializer) user = self.get_user(serializer)
self._insert_connect_options(data, user)
asset = data.get('asset') asset = data.get('asset')
account_name = data.get('account') account_name = data.get('account')
_data = self._validate(user, asset, account_name) _data = self._validate(user, asset, account_name)
@ -363,7 +376,7 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
def _validate_acl(self, user, asset, account): def _validate_acl(self, user, asset, account):
from acls.models import LoginAssetACL 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) ip = get_request_ip(self.request)
acl = LoginAssetACL.get_match_rule_acls(user, ip, acls) acl = LoginAssetACL.get_match_rule_acls(user, ip, acls)
if not acl: if not acl:

View File

@ -1,13 +1,13 @@
from rest_framework.views import APIView
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response 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 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__) logger = get_logger(__file__)
@ -27,7 +27,7 @@ class DingTalkQRUnBindBase(APIView):
class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase): class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase):
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),) permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase): class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase):

View File

@ -1,13 +1,13 @@
from rest_framework.views import APIView
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response 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 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__) logger = get_logger(__file__)
@ -27,7 +27,7 @@ class FeiShuQRUnBindBase(APIView):
class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase): class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase):
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),) permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase): class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase):
@ -38,7 +38,7 @@ class FeiShuEventSubscriptionCallback(APIView):
""" """
# https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM # https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM
""" """
permission_classes = () permission_classes = (IsValidUser,)
def post(self, request: Request, *args, **kwargs): def post(self, request: Request, *args, **kwargs):
return Response(data=request.data) return Response(data=request.data)

View File

@ -3,6 +3,7 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework import exceptions
from rest_framework.generics import CreateAPIView from rest_framework.generics import CreateAPIView
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
@ -13,6 +14,7 @@ from common.utils import get_logger
from users.models.user import User from users.models.user import User
from .. import errors from .. import errors
from .. import serializers from .. import serializers
from ..errors import SessionEmptyError
from ..mixins import AuthMixin from ..mixins import AuthMixin
logger = get_logger(__name__) logger = get_logger(__name__)
@ -56,6 +58,7 @@ class MFASendCodeApi(AuthMixin, CreateAPIView):
if not mfa_backend or not mfa_backend.challenge_required: if not mfa_backend or not mfa_backend.challenge_required:
error = _('Current user not support mfa type: {}').format(mfa_type) error = _('Current user not support mfa type: {}').format(mfa_type)
raise ValidationError({'error': error}) raise ValidationError({'error': error})
try: try:
mfa_backend.send_challenge() mfa_backend.send_challenge()
except Exception as e: except Exception as e:
@ -66,6 +69,15 @@ class MFAChallengeVerifyApi(AuthMixin, CreateAPIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
serializer_class = serializers.MFAChallengeSerializer 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): def perform_create(self, serializer):
user = self.get_user_from_session() user = self.get_user_from_session()
code = serializer.validated_data.get('code') code = serializer.validated_data.get('code')

View File

@ -1,26 +1,27 @@
from uuid import UUID
from urllib.parse import urlencode from urllib.parse import urlencode
from uuid import UUID
from django.contrib.auth import login
from django.conf import settings from django.conf import settings
from django.contrib.auth import login
from django.http.response import HttpResponseRedirect from django.http.response import HttpResponseRedirect
from rest_framework import serializers
from rest_framework.decorators import action 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.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.api import JMSGenericViewSet
from common.serializers import EmptySerializer from common.const.http import POST, GET
from common.permissions import OnlySuperUser from common.permissions import OnlySuperUser
from common.serializers import EmptySerializer
from common.utils import reverse from common.utils import reverse
from common.utils.timezone import utc_now
from users.models import User from users.models import User
from ..serializers import SSOTokenSerializer from ..errors import SSOAuthClosed
from ..models import SSOToken
from ..filters import AuthKeyQueryDeclaration from ..filters import AuthKeyQueryDeclaration
from ..mixins import AuthMixin from ..mixins import AuthMixin
from ..errors import SSOAuthClosed from ..models import SSOToken
from ..serializers import SSOTokenSerializer
NEXT_URL = 'next' NEXT_URL = 'next'
AUTH_KEY = 'authkey' AUTH_KEY = 'authkey'
@ -67,6 +68,9 @@ class SSOViewSet(AuthMixin, JMSGenericViewSet):
if not next_url or not next_url.startswith('/'): if not next_url or not next_url.startswith('/'):
next_url = reverse('index') next_url = reverse('index')
if not authkey:
raise serializers.ValidationError("authkey is required")
try: try:
authkey = UUID(authkey) authkey = UUID(authkey)
token = SSOToken.objects.get(authkey=authkey, expired=False) token = SSOToken.objects.get(authkey=authkey, expired=False)

View File

@ -1,13 +1,13 @@
from rest_framework.views import APIView
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response 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 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__) logger = get_logger(__file__)
@ -27,7 +27,7 @@ class WeComQRUnBindBase(APIView):
class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase): class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase):
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),) permission_classes = (IsValidUser, UserConfirmation.require(ConfirmType.ReLogin),)
class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase): class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase):

View File

@ -0,0 +1 @@
from .backends import *

View File

@ -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)

View File

@ -0,0 +1,9 @@
from django.conf import settings
from ..base import JMSModelBackend
class PasskeyAuthBackend(JMSModelBackend):
@staticmethod
def is_enabled():
return settings.AUTH_PASSKEY

View File

@ -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

View File

@ -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

View File

@ -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'})

View File

@ -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

View File

@ -146,7 +146,9 @@ class PrepareRequestMixin:
}, },
'singleLogoutService': { 'singleLogoutService': {
'url': f"{sp_host}{reverse('authentication:saml2:saml2-logout')}" '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) sp_settings['sp'].update(attrs)

View File

@ -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,
},
),
]

View File

@ -132,11 +132,11 @@ class CommonMixin:
return user return user
user_id = self.request.session.get('user_id') 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_at = self.request.session.get('auth_password_expired_at')
auth_expired = auth_expired_at < time.time() if auth_expired_at else False 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() raise errors.SessionEmptyError()
user = get_object_or_404(User, pk=user_id) 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 request.session['auto_login'] = auto_login
if not auth_backend: if not auth_backend:
auth_backend = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL) auth_backend = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL)
request.session['auth_backend'] = auth_backend request.session['auth_backend'] = auth_backend
def check_oauth2_auth(self, user: User, 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): def clear_auth_mark(self):
keys = [ 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: for k in keys:
self.request.session.pop(k, '') self.request.session.pop(k, '')

View File

@ -7,6 +7,7 @@ from django.dispatch import receiver
from django_cas_ng.signals import cas_user_authenticated from django_cas_ng.signals import cas_user_authenticated
from apps.jumpserver.settings.auth import AUTHENTICATION_BACKENDS_THIRD_PARTY 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 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 \ if not request.session.get("auth_third_party_done") and \
request.session.get('auth_backend') in AUTHENTICATION_BACKENDS_THIRD_PARTY: request.session.get('auth_backend') in AUTHENTICATION_BACKENDS_THIRD_PARTY:
request.session['auth_third_party_required'] = 1 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: if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED:
lock_key = 'single_machine_login_' + str(user.id) 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: if session_key and session_key != request.session.session_key:
session = import_module(settings.SESSION_ENGINE).SessionStore(session_key) session = import_module(settings.SESSION_ENGINE).SessionStore(session_key)
session.delete() session.delete()
UserSession.objects.filter(key=session_key).delete()
cache.set(lock_key, request.session.session_key, None) cache.set(lock_key, request.session.session_key, None)
# 标记登录,设置 cookie前端可以控制刷新, Middleware 会拦截这个生成 cookie # 标记登录,设置 cookie前端可以控制刷新, Middleware 会拦截这个生成 cookie

View File

@ -223,10 +223,55 @@
height: 13px; height: 13px;
cursor: pointer; 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;
}
}
</style> </style>
</head> </head>
<body> <body>
{% if error_origin %}
<div class='alert alert-danger error-info'>
{% trans 'Configuration file has problems and cannot be logged in. Please contact the administrator or view latest docs' %}<br/>
{% trans 'If you are administrator, you can update the config resolve it, set' %} <br/>
DOMAINS={{ error_origin }}
</div>
{% endif %}
<div class="login-content extra-fields-{{ extra_fields_count }}"> <div class="login-content extra-fields-{{ extra_fields_count }}">
<div class="right-image-box"> <div class="right-image-box">
<a href="{% if not XPACK_ENABLED %}https://github.com/jumpserver/jumpserver.git{% endif %}"> <a href="{% if not XPACK_ENABLED %}https://github.com/jumpserver/jumpserver.git{% endif %}">
@ -234,6 +279,11 @@
</a> </a>
</div> </div>
<div class="left-form-box {% if not form.challenge and not form.captcha %} no-captcha-challenge {% endif %}"> <div class="left-form-box {% if not form.challenge and not form.captcha %} no-captcha-challenge {% endif %}">
<div class="mobile-logo">
<a href="{% if not XPACK_ENABLED %}https://github.com/jumpserver/jumpserver.git{% endif %}">
<img src="{% static 'img/logo_text_green.png' %}" class="right-image" alt="screen-image"/>
</a>
</div>
<div style="position: relative;top: 50%;transform: translateY(-50%);"> <div style="position: relative;top: 50%;transform: translateY(-50%);">
<div style='padding: 15px 60px; text-align: left'> <div style='padding: 15px 60px; text-align: left'>
<h2 style='font-weight: 400;display: inline'> <h2 style='font-weight: 400;display: inline'>
@ -294,13 +344,13 @@
{% endif %} {% endif %}
<div class="form-group auto-login" style="margin-bottom: 10px"> <div class="form-group auto-login" style="margin-bottom: 10px">
<div class="row" style="overflow: hidden;"> <div class="row" style="overflow: hidden;">
<div class="col-md-6" style="text-align: left"> <div class="col-md-6 col-xs-6" style="text-align: left">
{% if form.auto_login %} {% if form.auto_login %}
{% bootstrap_field form.auto_login form_group_class='auto_login_box' %} {% bootstrap_field form.auto_login form_group_class='auto_login_box' %}
{% endif %} {% endif %}
</div> </div>
<div class="col-md-6" style="line-height: 25px"> <div class="col-md-6 col-xs-6" style="line-height: 25px">
<a id="forgot_password" href="{{ forgot_password_url }}" style="float: right"> <a id="forgot_password" href="{{ forgot_password_url }}" style="float: right">
<small>{% trans 'Forgot password' %}?</small> <small>{% trans 'Forgot password' %}?</small>
</a> </a>

View File

@ -0,0 +1,191 @@
{% load static %}
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login passkey</title>
<script src="{% static "js/jquery-3.6.1.min.js" %}?_=9"></script>
</head>
<body>
<form action='{% url 'api-auth:passkey-auth' %}' method="post" id="loginForm">
<input type="hidden" name="passkeys" id="passkeys"/>
</form>
</body>
<script>
const loginUrl = "/core/auth/login/";
window.conditionalUI = false;
window.conditionUIAbortController = new AbortController();
window.conditionUIAbortSignal = conditionUIAbortController.signal;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
// Use a lookup table to find the index.
const lookup = new Uint8Array(256)
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i
}
const encode = function (arraybuffer) {
const bytes = new Uint8Array(arraybuffer)
let i;
const len = bytes.length;
let base64url = ''
for (i = 0; i < len; i += 3) {
base64url += chars[bytes[i] >> 2]
base64url += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]
base64url += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]
base64url += chars[bytes[i + 2] & 63]
}
if ((len % 3) === 2) {
base64url = base64url.substring(0, base64url.length - 1)
} else if (len % 3 === 1) {
base64url = base64url.substring(0, base64url.length - 2)
}
return base64url
}
const decode = function (base64string) {
const bufferLength = base64string.length * 0.75
const len = base64string.length;
let i;
let p = 0
let encoded1;
let encoded2;
let encoded3;
let encoded4
const bytes = new Uint8Array(bufferLength)
for (i = 0; i < len; i += 4) {
encoded1 = lookup[base64string.charCodeAt(i)]
encoded2 = lookup[base64string.charCodeAt(i + 1)]
encoded3 = lookup[base64string.charCodeAt(i + 2)]
encoded4 = lookup[base64string.charCodeAt(i + 3)]
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4)
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2)
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63)
}
return bytes.buffer
}
function checkConditionalUI(form) {
if (!navigator.credentials) {
alert('WebAuthn is not supported in this browser')
return
}
if (window.PublicKeyCredential && PublicKeyCredential.isConditionalMediationAvailable) {
// Check if conditional mediation is available.
PublicKeyCredential.isConditionalMediationAvailable().then((result) => {
window.conditionalUI = result;
if (!window.conditionalUI) {
alert("Conditional UI is not available. Please use the legacy UI.");
} else {
return true
}
});
}
}
const publicKeyCredentialToJSON = (pubKeyCred) => {
if (pubKeyCred instanceof Array) {
const arr = []
for (const i of pubKeyCred) {
arr.push(publicKeyCredentialToJSON(i))
}
return arr
}
if (pubKeyCred instanceof ArrayBuffer) {
return encode(pubKeyCred)
}
if (pubKeyCred instanceof Object) {
const obj = {}
for (const key in pubKeyCred) {
obj[key] = publicKeyCredentialToJSON(pubKeyCred[key])
}
return obj
}
return pubKeyCred
}
function GetAssertReq(getAssert) {
getAssert.publicKey.challenge = decode(getAssert.publicKey.challenge)
for (const allowCred of getAssert.publicKey.allowCredentials) {
allowCred.id = decode(allowCred.id)
}
return getAssert
}
function startAuthn(form, conditionalUI = false) {
window.loginForm = form
fetch('/api/v1/authentication/passkeys/auth/', {method: 'GET'}).then(function (response) {
if (response.ok) {
return response.json().then(function (req) {
return GetAssertReq(req)
})
}
throw new Error('No credential available to authenticate!')
}).then(function (options) {
if (conditionalUI) {
options.mediation = 'conditional'
options.signal = window.conditionUIAbortSignal
} else {
window.conditionUIAbortController.abort()
}
return navigator.credentials.get(options)
}).then(function (assertion) {
const pk = $('#passkeys')
if (pk.length === 0) {
retry("Did you add the 'passkeys' hidden input field")
return
}
pk.val(JSON.stringify(publicKeyCredentialToJSON(assertion)))
const x = document.getElementById(window.loginForm)
if (x === null || x === undefined) {
console.error('Did you pass the correct form id to auth function')
return
}
x.submit()
}).catch(function (err) {
retry(err)
})
}
function safeStartAuthn(form) {
checkConditionalUI('loginForm')
const errorMsg = "{% trans 'This page is not served over HTTPS. Please use HTTPS to ensure security of your credentials.' %}"
const isSafe = window.location.protocol === 'https:'
if (!isSafe && location.hostname !== 'localhost') {
alert(errorMsg)
window.location.href = loginUrl
} else {
setTimeout(() => startAuthn('loginForm'), 100)
}
}
function retry(error) {
const fullError = "{% trans 'Error' %}" + ': ' + error + "\n\n " + "{% trans 'Do you want to retry ?' %}"
const result = confirm(fullError)
if (result) {
safeStartAuthn()
} else {
window.location.href = loginUrl
}
}
{% if not error %}
window.onload = function () {
safeStartAuthn()
}
{% else %}
const error = "{{ error }}"
retry(error)
{% endif %}
</script>
</html>

View File

@ -4,6 +4,7 @@ from django.urls import path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .. import api from .. import api
from ..backends.passkey.urls import urlpatterns as passkey_urlpatterns
app_name = 'authentication' app_name = 'authentication'
router = DefaultRouter() router = DefaultRouter()
@ -13,17 +14,19 @@ router.register('temp-tokens', api.TempTokenViewSet, 'temp-token')
router.register('connection-token', api.ConnectionTokenViewSet, 'connection-token') router.register('connection-token', api.ConnectionTokenViewSet, 'connection-token')
router.register('super-connection-token', api.SuperConnectionTokenViewSet, 'super-connection-token') router.register('super-connection-token', api.SuperConnectionTokenViewSet, 'super-connection-token')
urlpatterns = [ urlpatterns = [
path('wecom/qr/unbind/', api.WeComQRUnBindForUserApi.as_view(), name='wecom-qr-unbind'), path('wecom/qr/unbind/', api.WeComQRUnBindForUserApi.as_view(), name='wecom-qr-unbind'),
path('wecom/qr/unbind/<uuid:user_id>/', api.WeComQRUnBindForAdminApi.as_view(), name='wecom-qr-unbind-for-admin'), path('wecom/qr/unbind/<uuid:user_id>/', api.WeComQRUnBindForAdminApi.as_view(), name='wecom-qr-unbind-for-admin'),
path('dingtalk/qr/unbind/', api.DingTalkQRUnBindForUserApi.as_view(), name='dingtalk-qr-unbind'), path('dingtalk/qr/unbind/', api.DingTalkQRUnBindForUserApi.as_view(), name='dingtalk-qr-unbind'),
path('dingtalk/qr/unbind/<uuid:user_id>/', api.DingTalkQRUnBindForAdminApi.as_view(), name='dingtalk-qr-unbind-for-admin'), path('dingtalk/qr/unbind/<uuid:user_id>/', api.DingTalkQRUnBindForAdminApi.as_view(),
name='dingtalk-qr-unbind-for-admin'),
path('feishu/qr/unbind/', api.FeiShuQRUnBindForUserApi.as_view(), name='feishu-qr-unbind'), path('feishu/qr/unbind/', api.FeiShuQRUnBindForUserApi.as_view(), name='feishu-qr-unbind'),
path('feishu/qr/unbind/<uuid:user_id>/', api.FeiShuQRUnBindForAdminApi.as_view(), name='feishu-qr-unbind-for-admin'), path('feishu/qr/unbind/<uuid:user_id>/', api.FeiShuQRUnBindForAdminApi.as_view(),
path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(), name='feishu-event-subscription-callback'), name='feishu-qr-unbind-for-admin'),
path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(),
name='feishu-event-subscription-callback'),
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'), path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('confirm/', api.ConfirmApi.as_view(), name='user-confirm'), path('confirm/', api.ConfirmApi.as_view(), name='user-confirm'),
@ -38,4 +41,4 @@ urlpatterns = [
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'), path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
] ]
urlpatterns += router.urls urlpatterns += router.urls + passkey_urlpatterns

View File

@ -1,11 +1,11 @@
# coding:utf-8 # coding:utf-8
# #
from django.urls import path, include
from django.db.transaction import non_atomic_requests from django.db.transaction import non_atomic_requests
from django.urls import path, include
from .. import views
from users import views as users_view from users import views as users_view
from .. import views
app_name = 'authentication' app_name = 'authentication'
@ -18,7 +18,8 @@ urlpatterns = [
path('logout/', views.UserLogoutView.as_view(), name='logout'), path('logout/', views.UserLogoutView.as_view(), name='logout'),
# 原来在users中的 # 原来在users中的
path('password/forget/previewing/', users_view.UserForgotPasswordPreviewingView.as_view(), name='forgot-previewing'), path('password/forget/previewing/', users_view.UserForgotPasswordPreviewingView.as_view(),
name='forgot-previewing'),
path('password/forgot/', users_view.UserForgotPasswordView.as_view(), name='forgot-password'), path('password/forgot/', users_view.UserForgotPasswordView.as_view(), name='forgot-password'),
path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'), path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'),
path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-password'), path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-password'),
@ -26,7 +27,8 @@ urlpatterns = [
path('wecom/bind/start/', views.WeComEnableStartView.as_view(), name='wecom-bind-start'), path('wecom/bind/start/', views.WeComEnableStartView.as_view(), name='wecom-bind-start'),
path('wecom/qr/bind/', views.WeComQRBindView.as_view(), name='wecom-qr-bind'), path('wecom/qr/bind/', views.WeComQRBindView.as_view(), name='wecom-qr-bind'),
path('wecom/qr/login/', views.WeComQRLoginView.as_view(), name='wecom-qr-login'), path('wecom/qr/login/', views.WeComQRLoginView.as_view(), name='wecom-qr-login'),
path('wecom/qr/bind/<uuid:user_id>/callback/', views.WeComQRBindCallbackView.as_view(), name='wecom-qr-bind-callback'), path('wecom/qr/bind/<uuid:user_id>/callback/', views.WeComQRBindCallbackView.as_view(),
name='wecom-qr-bind-callback'),
path('wecom/qr/login/callback/', views.WeComQRLoginCallbackView.as_view(), name='wecom-qr-login-callback'), path('wecom/qr/login/callback/', views.WeComQRLoginCallbackView.as_view(), name='wecom-qr-login-callback'),
path('wecom/oauth/login/', views.WeComOAuthLoginView.as_view(), name='wecom-oauth-login'), path('wecom/oauth/login/', views.WeComOAuthLoginView.as_view(), name='wecom-oauth-login'),
path('wecom/oauth/login/callback/', views.WeComOAuthLoginCallbackView.as_view(), name='wecom-oauth-login-callback'), path('wecom/oauth/login/callback/', views.WeComOAuthLoginCallbackView.as_view(), name='wecom-oauth-login-callback'),
@ -34,10 +36,12 @@ urlpatterns = [
path('dingtalk/bind/start/', views.DingTalkEnableStartView.as_view(), name='dingtalk-bind-start'), path('dingtalk/bind/start/', views.DingTalkEnableStartView.as_view(), name='dingtalk-bind-start'),
path('dingtalk/qr/bind/', views.DingTalkQRBindView.as_view(), name='dingtalk-qr-bind'), path('dingtalk/qr/bind/', views.DingTalkQRBindView.as_view(), name='dingtalk-qr-bind'),
path('dingtalk/qr/login/', views.DingTalkQRLoginView.as_view(), name='dingtalk-qr-login'), path('dingtalk/qr/login/', views.DingTalkQRLoginView.as_view(), name='dingtalk-qr-login'),
path('dingtalk/qr/bind/<uuid:user_id>/callback/', views.DingTalkQRBindCallbackView.as_view(), name='dingtalk-qr-bind-callback'), path('dingtalk/qr/bind/<uuid:user_id>/callback/', views.DingTalkQRBindCallbackView.as_view(),
name='dingtalk-qr-bind-callback'),
path('dingtalk/qr/login/callback/', views.DingTalkQRLoginCallbackView.as_view(), name='dingtalk-qr-login-callback'), path('dingtalk/qr/login/callback/', views.DingTalkQRLoginCallbackView.as_view(), name='dingtalk-qr-login-callback'),
path('dingtalk/oauth/login/', views.DingTalkOAuthLoginView.as_view(), name='dingtalk-oauth-login'), path('dingtalk/oauth/login/', views.DingTalkOAuthLoginView.as_view(), name='dingtalk-oauth-login'),
path('dingtalk/oauth/login/callback/', views.DingTalkOAuthLoginCallbackView.as_view(), name='dingtalk-oauth-login-callback'), path('dingtalk/oauth/login/callback/', views.DingTalkOAuthLoginCallbackView.as_view(),
name='dingtalk-oauth-login-callback'),
path('feishu/bind/start/', views.FeiShuEnableStartView.as_view(), name='feishu-bind-start'), path('feishu/bind/start/', views.FeiShuEnableStartView.as_view(), name='feishu-bind-start'),
path('feishu/qr/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'), path('feishu/qr/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'),

View File

@ -12,6 +12,7 @@ from authentication.mixins import AuthMixin
from common.utils import get_logger from common.utils import get_logger
from common.utils.django import reverse, get_object_or_none from common.utils.django import reverse, get_object_or_none
from users.models import User from users.models import User
from users.signal_handlers import check_only_allow_exist_user_auth
from .mixins import FlashMessageMixin from .mixins import FlashMessageMixin
logger = get_logger(__file__) logger = get_logger(__file__)
@ -49,6 +50,11 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, View):
user, create = User.objects.get_or_create( user, create = User.objects.get_or_create(
username=user_attr['username'], defaults=user_attr username=user_attr['username'], defaults=user_attr
) )
if not check_only_allow_exist_user_auth(create):
user.delete()
return user, (self.msg_client_err, self.request.error_message)
setattr(user, f'{self.user_type}_id', user_id) setattr(user, f'{self.user_type}_id', user_id)
if create: if create:
setattr(user, 'source', self.user_type) setattr(user, 'source', self.user_type)

View File

@ -6,6 +6,7 @@ from __future__ import unicode_literals
import datetime import datetime
import os import os
from typing import Callable from typing import Callable
from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY from django.contrib.auth import BACKEND_SESSION_KEY
@ -40,6 +41,7 @@ __all__ = [
class UserLoginContextMixin: class UserLoginContextMixin:
get_user_mfa_context: Callable get_user_mfa_context: Callable
request: HttpRequest request: HttpRequest
error_origin: str
def get_support_auth_methods(self): def get_support_auth_methods(self):
auth_methods = [ auth_methods = [
@ -88,6 +90,12 @@ class UserLoginContextMixin:
'enabled': settings.AUTH_FEISHU, 'enabled': settings.AUTH_FEISHU,
'url': reverse('authentication:feishu-qr-login'), 'url': reverse('authentication:feishu-qr-login'),
'logo': static('img/login_feishu_logo.png') 'logo': static('img/login_feishu_logo.png')
},
{
'name': _("Passkey"),
'enabled': settings.AUTH_PASSKEY,
'url': reverse('api-auth:passkey-login'),
'logo': static('img/login_passkey.png')
} }
] ]
return [method for method in auth_methods if method['enabled']] return [method for method in auth_methods if method['enabled']]
@ -134,8 +142,27 @@ class UserLoginContextMixin:
count += 1 count += 1
return count return count
def set_csrf_error_if_need(self, context):
if not self.request.GET.get('csrf_failure'):
return context
http_origin = self.request.META.get('HTTP_ORIGIN')
http_referer = self.request.META.get('HTTP_REFERER')
http_origin = http_origin or http_referer
if not http_origin:
return context
try:
origin = urlparse(http_origin)
context['error_origin'] = str(origin.netloc)
except ValueError:
pass
return context
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
self.set_csrf_error_if_need(context)
context.update({ context.update({
'demo_mode': os.environ.get("DEMO_MODE"), 'demo_mode': os.environ.get("DEMO_MODE"),
'auth_methods': self.get_support_auth_methods(), 'auth_methods': self.get_support_auth_methods(),
@ -283,6 +310,12 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView):
age = self.request.session.get_expiry_age() age = self.request.session.get_expiry_age()
self.request.session.set_expiry(age) self.request.session.set_expiry(age)
def get(self, request, *args, **kwargs):
response = super().get(request, *args, **kwargs)
if request.user.is_authenticated:
response.set_cookie('jms_username', request.user.username)
return response
def get_redirect_url(self, *args, **kwargs): def get_redirect_url(self, *args, **kwargs):
try: try:
user = self.get_user_from_session() user = self.get_user_from_session()

View File

@ -16,12 +16,19 @@ class METAMixin:
class FlashMessageMixin: class FlashMessageMixin:
@staticmethod @staticmethod
def get_response(redirect_url, title, msg, m_type='message'): def get_response(redirect_url='', title='', msg='', m_type='message', interval=5):
message_data = {'title': title, 'interval': 5, 'redirect_url': redirect_url, m_type: msg} message_data = {
'title': title, 'interval': interval,
'redirect_url': redirect_url,
}
if m_type == 'error':
message_data['error'] = msg
else:
message_data['message'] = msg
return FlashMessageUtil.gen_and_redirect_to(message_data) return FlashMessageUtil.gen_and_redirect_to(message_data)
def get_success_response(self, redirect_url, title, msg): def get_success_response(self, redirect_url, title, msg, **kwargs):
return self.get_response(redirect_url, title, msg) return self.get_response(redirect_url, title, msg, m_type='success', **kwargs)
def get_failed_response(self, redirect_url, title, msg): def get_failed_response(self, redirect_url, title, msg, interval=10):
return self.get_response(redirect_url, title, msg, 'error') return self.get_response(redirect_url, title, msg, 'error', interval)

View File

@ -1,6 +1,5 @@
from .action import * from .action import *
from .common import * from .common import *
from .filter import *
from .generic import * from .generic import *
from .mixin import * from .mixin import *
from .patch import * from .patch import *

View File

@ -1,92 +0,0 @@
# -*- coding: utf-8 -*-
#
import logging
from itertools import chain
from django.db import models
from rest_framework.settings import api_settings
from common.drf.filters import IDSpmFilter, CustomFilter, IDInFilter
__all__ = ['ExtraFilterFieldsMixin', 'OrderingFielderFieldsMixin']
logger = logging.getLogger('jumpserver.common')
class ExtraFilterFieldsMixin:
"""
额外的 api filter
"""
default_added_filters = [CustomFilter, IDSpmFilter, IDInFilter]
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
extra_filter_fields = []
extra_filter_backends = []
def get_filter_backends(self):
if self.filter_backends != self.__class__.filter_backends:
return self.filter_backends
backends = list(chain(
self.filter_backends,
self.default_added_filters,
self.extra_filter_backends
))
return backends
def filter_queryset(self, queryset):
for backend in self.get_filter_backends():
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
class OrderingFielderFieldsMixin:
"""
额外的 api ordering
"""
ordering_fields = None
extra_ordering_fields = []
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ordering_fields = self._get_ordering_fields()
def _get_ordering_fields(self):
if isinstance(self.__class__.ordering_fields, (list, tuple)):
return self.__class__.ordering_fields
try:
valid_fields = self.get_valid_ordering_fields()
except Exception as e:
logger.debug('get_valid_ordering_fields error: %s' % e)
# 这里千万不要这么用,会让 logging 重复,至于为什么,我也不知道
# logging.debug('get_valid_ordering_fields error: %s' % e)
valid_fields = []
fields = list(chain(
valid_fields,
self.extra_ordering_fields
))
return fields
def get_valid_ordering_fields(self):
if getattr(self, 'model', None):
model = self.model
elif getattr(self, 'queryset', None):
model = self.queryset.model
else:
queryset = self.get_queryset()
model = queryset.model
if not model:
return []
excludes_fields = (
models.UUIDField, models.Model, models.ForeignKey,
models.FileField, models.JSONField, models.ManyToManyField,
models.DurationField,
)
valid_fields = []
for field in model._meta.fields:
if isinstance(field, excludes_fields):
continue
valid_fields.append(field.name)
return valid_fields

View File

@ -1,19 +1,29 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from collections import defaultdict from collections import defaultdict
from itertools import chain
from typing import Callable from typing import Callable
from django.db import models
from django.db.models.signals import m2m_changed from django.db.models.signals import m2m_changed
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.settings import api_settings
from common.drf.filters import (
IDSpmFilterBackend, CustomFilterBackend, IDInFilterBackend,
IDNotFilterBackend, NotOrRelFilterBackend
)
from common.utils import get_logger
from .action import RenderToJsonMixin from .action import RenderToJsonMixin
from .filter import ExtraFilterFieldsMixin, OrderingFielderFieldsMixin
from .serializer import SerializerMixin from .serializer import SerializerMixin
__all__ = [ __all__ = [
'CommonApiMixin', 'PaginatedResponseMixin', 'RelationMixin', 'CommonApiMixin', 'PaginatedResponseMixin', 'RelationMixin',
'ExtraFilterFieldsMixin',
] ]
logger = get_logger(__name__)
class PaginatedResponseMixin: class PaginatedResponseMixin:
@ -95,6 +105,100 @@ class QuerySetMixin:
return queryset return queryset
class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin, OrderingFielderFieldsMixin, class ExtraFilterFieldsMixin:
QuerySetMixin, RenderToJsonMixin, PaginatedResponseMixin): """
额外的 api filter
"""
default_added_filters = (
CustomFilterBackend, IDSpmFilterBackend, IDInFilterBackend,
IDNotFilterBackend,
)
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
extra_filter_fields = []
extra_filter_backends = []
def set_compatible_fields(self):
"""
兼容老的 filter_fields
"""
if not hasattr(self, 'filter_fields') and hasattr(self, 'filterset_fields'):
self.filter_fields = self.filterset_fields
def get_filter_backends(self):
self.set_compatible_fields()
if self.filter_backends != self.__class__.filter_backends:
return self.filter_backends
backends = list(chain(
self.filter_backends,
self.default_added_filters,
self.extra_filter_backends,
))
# 这个要放在最后
backends.append(NotOrRelFilterBackend)
return backends
def filter_queryset(self, queryset):
for backend in self.get_filter_backends():
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
class OrderingFielderFieldsMixin:
"""
额外的 api ordering
"""
ordering_fields = None
extra_ordering_fields = []
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ordering_fields = self._get_ordering_fields()
def _get_ordering_fields(self):
if isinstance(self.__class__.ordering_fields, (list, tuple)):
return self.__class__.ordering_fields
try:
valid_fields = self.get_valid_ordering_fields()
except Exception as e:
logger.debug('get_valid_ordering_fields error: %s, pass' % e)
# 这里千万不要这么用,会让 logging 重复,至于为什么,我也不知道
# logging.debug('get_valid_ordering_fields error: %s' % e)
valid_fields = []
fields = list(chain(
valid_fields,
self.extra_ordering_fields
))
return fields
def get_valid_ordering_fields(self):
if getattr(self, 'model', None):
model = self.model
elif getattr(self, 'queryset', None):
model = self.queryset.model
else:
queryset = self.get_queryset()
model = queryset.model
if not model:
return []
excludes_fields = (
models.UUIDField, models.Model, models.ForeignKey,
models.FileField, models.JSONField, models.ManyToManyField,
models.DurationField,
)
valid_fields = []
for field in model._meta.fields:
if isinstance(field, excludes_fields):
continue
valid_fields.append(field.name)
return valid_fields
class CommonApiMixin(
SerializerMixin, ExtraFilterFieldsMixin, OrderingFielderFieldsMixin,
QuerySetMixin, RenderToJsonMixin, PaginatedResponseMixin
):
pass pass

View File

@ -88,6 +88,7 @@ class AsyncApiMixin(InterceptMixin):
if not self.is_need_async(): if not self.is_need_async():
return handler(*args, **kwargs) return handler(*args, **kwargs)
resp = self.do_async(handler, *args, **kwargs) resp = self.do_async(handler, *args, **kwargs)
self.async_callback(*args, **kwargs)
return resp return resp
def is_need_refresh(self): def is_need_refresh(self):
@ -98,6 +99,9 @@ class AsyncApiMixin(InterceptMixin):
def is_need_async(self): def is_need_async(self):
return False return False
def async_callback(self, *args, **kwargs):
pass
def do_async(self, handler, *args, **kwargs): def do_async(self, handler, *args, **kwargs):
data = self.get_cache_data() data = self.get_cache_data()
if not data: if not data:

View File

@ -13,17 +13,17 @@ from rest_framework.fields import DateTimeField
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from common import const from common import const
from common.db.fields import RelatedManager
logger = logging.getLogger('jumpserver.common') logger = logging.getLogger('jumpserver.common')
__all__ = [ __all__ = [
"DatetimeRangeFilter", "IDSpmFilter", "DatetimeRangeFilterBackend", "IDSpmFilterBackend",
'IDInFilter', "CustomFilter", 'IDInFilterBackend', "CustomFilterBackend",
"BaseFilterSet" "BaseFilterSet", 'IDNotFilterBackend',
'NotOrRelFilterBackend',
] ]
from common.db.fields import RelatedManager
class BaseFilterSet(drf_filters.FilterSet): class BaseFilterSet(drf_filters.FilterSet):
def do_nothing(self, queryset, name, value): def do_nothing(self, queryset, name, value):
@ -35,7 +35,7 @@ class BaseFilterSet(drf_filters.FilterSet):
return default return default
class DatetimeRangeFilter(filters.BaseFilterBackend): class DatetimeRangeFilterBackend(filters.BaseFilterBackend):
def get_schema_fields(self, view): def get_schema_fields(self, view):
ret = [] ret = []
fields = self._get_date_range_filter_fields(view) fields = self._get_date_range_filter_fields(view)
@ -102,7 +102,7 @@ class DatetimeRangeFilter(filters.BaseFilterBackend):
return queryset return queryset
class IDSpmFilter(filters.BaseFilterBackend): class IDSpmFilterBackend(filters.BaseFilterBackend):
def get_schema_fields(self, view): def get_schema_fields(self, view):
return [ return [
coreapi.Field( coreapi.Field(
@ -130,7 +130,7 @@ class IDSpmFilter(filters.BaseFilterBackend):
return queryset return queryset
class IDInFilter(filters.BaseFilterBackend): class IDInFilterBackend(filters.BaseFilterBackend):
def get_schema_fields(self, view): def get_schema_fields(self, view):
return [ return [
coreapi.Field( coreapi.Field(
@ -149,7 +149,26 @@ class IDInFilter(filters.BaseFilterBackend):
return queryset return queryset
class CustomFilter(filters.BaseFilterBackend): class IDNotFilterBackend(filters.BaseFilterBackend):
def get_schema_fields(self, view):
return [
coreapi.Field(
name='id!', location='query', required=False,
type='string', example='/api/v1/users/users?id!=1,2,3',
description='Exclude by id set'
)
]
def filter_queryset(self, request, queryset, view):
ids = request.query_params.get('id!')
if not ids:
return queryset
id_list = [i.strip() for i in ids.split(',')]
queryset = queryset.exclude(id__in=id_list)
return queryset
class CustomFilterBackend(filters.BaseFilterBackend):
def get_schema_fields(self, view): def get_schema_fields(self, view):
fields = [] fields = []
@ -218,3 +237,25 @@ class AttrRulesFilterBackend(filters.BaseFilterBackend):
logger.debug('attr_rules: %s', attr_rules) logger.debug('attr_rules: %s', attr_rules)
q = RelatedManager.get_to_filter_q(attr_rules, queryset.model) q = RelatedManager.get_to_filter_q(attr_rules, queryset.model)
return queryset.filter(q).distinct() return queryset.filter(q).distinct()
class NotOrRelFilterBackend(filters.BaseFilterBackend):
def get_schema_fields(self, view):
return [
coreapi.Field(
name='_rel', location='query', required=False,
type='string', example='/api/v1/users/users?name=abc&username=def&_rel=union',
description='Filter by rel, or not, default is and'
)
]
def filter_queryset(self, request, queryset, view):
_rel = request.query_params.get('_rel')
if not _rel or _rel not in ('or', 'not'):
return queryset
if _rel == 'not':
queryset.query.where.negated = True
elif _rel == 'or':
queryset.query.where.connector = 'OR'
queryset._result_cache = None
return queryset

View File

@ -8,7 +8,8 @@ class PassthroughRenderer(renderers.BaseRenderer):
""" """
Return data as-is. View should supply a Response. Return data as-is. View should supply a Response.
""" """
media_type = '' media_type = 'application/octet-stream'
format = '' format = ''
def render(self, data, accepted_media_type=None, renderer_context=None): def render(self, data, accepted_media_type=None, renderer_context=None):
return data return data

View File

@ -0,0 +1,126 @@
import re
from django.conf import settings
from django.core.management.base import BaseCommand
from django.test import Client
from django.urls import URLPattern, URLResolver
from jumpserver.urls import api_v1
path_uuid_pattern = re.compile(r'<\w+:\w+>', re.IGNORECASE)
uuid_pattern = re.compile(r'\(\(\?P<.*>[^)]+\)/\)\?', re.IGNORECASE)
uuid2_pattern = re.compile(r'\(\?P<.*>\[\/\.\]\+\)', re.IGNORECASE)
uuid3_pattern = re.compile(r'\(\?P<.*>\[/\.]\+\)')
def list_urls(patterns, path=None):
""" recursive """
if not path:
path = []
result = []
for pattern in patterns:
if isinstance(pattern, URLPattern):
result.append(''.join(path) + str(pattern.pattern))
elif isinstance(pattern, URLResolver):
result += list_urls(pattern.url_patterns, path + [str(pattern.pattern)])
return result
def parse_to_url(url):
uid = '00000000-0000-0000-0000-000000000000'
url = url.replace('^', '')
url = url.replace('?$', '')
url = url.replace('(?P<format>[a-z0-9]+)', '')
url = url.replace('((?P<terminal>[/.]{36})/)?', uid + '/')
url = url.replace('(?P<pk>[/.]+)', uid)
url = url.replace('\.', '')
url = url.replace('//', '/')
url = url.strip('$')
url = re.sub(path_uuid_pattern, uid, url)
url = re.sub(uuid2_pattern, uid, url)
url = re.sub(uuid_pattern, uid + '/', url)
url = re.sub(uuid3_pattern, uid, url)
url = url.replace('(00000000-0000-0000-0000-000000000000/)?', uid + '/')
return url
def get_api_urls():
urls = []
api_urls = list_urls(api_v1)
for ourl in api_urls:
url = parse_to_url(ourl)
if 'render-to-json' in url:
continue
url = '/api/v1/' + url
urls.append((url, ourl))
return set(urls)
known_unauth_urls = [
"/api/v1/authentication/passkeys/auth/",
"/api/v1/prometheus/metrics/",
"/api/v1/authentication/auth/",
"/api/v1/settings/logo/",
"/api/v1/settings/public/open/",
"/api/v1/authentication/passkeys/login/",
"/api/v1/authentication/tokens/",
"/api/v1/authentication/mfa/challenge/",
"/api/v1/authentication/password/reset-code/",
"/api/v1/authentication/login-confirm-ticket/status/",
"/api/v1/authentication/mfa/select/",
"/api/v1/authentication/mfa/send-code/",
"/api/v1/authentication/sso/login/"
]
known_error_urls = [
'/api/v1/terminal/terminals/00000000-0000-0000-0000-000000000000/sessions/00000000-0000-0000-0000-000000000000/replay/download/',
'/api/v1/terminal/sessions/00000000-0000-0000-0000-000000000000/replay/download/',
]
errors = {}
class Command(BaseCommand):
help = 'Check api if unauthorized'
def handle(self, *args, **options):
settings.LOG_LEVEL = 'ERROR'
urls = get_api_urls()
client = Client()
unauth_urls = []
error_urls = []
unformat_urls = []
for url, ourl in urls:
if '(' in url or '<' in url:
unformat_urls.append([url, ourl])
continue
try:
response = client.get(url, follow=True)
if response.status_code != 401:
errors[url] = str(response.status_code) + ' ' + str(ourl)
unauth_urls.append(url)
except Exception as e:
errors[url] = str(e)
error_urls.append(url)
unauth_urls = set(unauth_urls) - set(known_unauth_urls)
print("\nUnauthorized urls:")
if not unauth_urls:
print(" Empty, very good!")
for url in unauth_urls:
print('"{}", {}'.format(url, errors.get(url, '')))
print("\nError urls:")
if not error_urls:
print(" Empty, very good!")
for url in set(error_urls):
print(url, ': ' + errors.get(url))
print("\nUnformat urls:")
if not unformat_urls:
print(" Empty, very good!")
for url in unformat_urls:
print(url)

View File

@ -12,7 +12,7 @@ from common.utils import get_object_or_none
from orgs.utils import tmp_to_root_org from orgs.utils import tmp_to_root_org
class IsValidUser(permissions.IsAuthenticated, permissions.BasePermission): class IsValidUser(permissions.IsAuthenticated):
"""Allows access to valid user, is active and not expired""" """Allows access to valid user, is active and not expired"""
def has_permission(self, request, view): def has_permission(self, request, view):

View File

@ -1,10 +1,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import struct
import random import random
import socket import socket
import string import string
import struct
string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~' string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~'
@ -18,35 +17,32 @@ def random_ip():
return socket.inet_ntoa(struct.pack('>I', random.randint(1, 0xffffffff))) return socket.inet_ntoa(struct.pack('>I', random.randint(1, 0xffffffff)))
def random_string(length: int, lower=True, upper=True, digit=True, special_char=False): def random_string(length: int, lower=True, upper=True, digit=True, special_char=False, symbols=string_punctuation):
args_names = ['lower', 'upper', 'digit', 'special_char'] random.seed()
args_values = [lower, upper, digit, special_char] args_names = ['lower', 'upper', 'digit']
args_string = [string.ascii_lowercase, string.ascii_uppercase, string.digits, string_punctuation] args_values = [lower, upper, digit]
args_string = [string.ascii_lowercase, string.ascii_uppercase, string.digits]
args_string_map = dict(zip(args_names, args_string)) args_string_map = dict(zip(args_names, args_string))
kwargs = dict(zip(args_names, args_values)) kwargs = dict(zip(args_names, args_values))
kwargs_keys = list(kwargs.keys()) kwargs_keys = list(kwargs.keys())
kwargs_values = list(kwargs.values()) kwargs_values = list(kwargs.values())
args_true_count = len([i for i in kwargs_values if i]) args_true_count = len([i for i in kwargs_values if i])
assert any(kwargs_values), f'Parameters {kwargs_keys} must have at least one `True`' assert any(kwargs_values), f'Parameters {kwargs_keys} must have at least one `True`'
assert length >= args_true_count, f'Expected length >= {args_true_count}, bug got {length}' assert length >= args_true_count, f'Expected length >= {args_true_count}, bug got {length}'
can_startswith_special_char = args_true_count == 1 and special_char
chars = ''.join([args_string_map[k] for k, v in kwargs.items() if v]) chars = ''.join([args_string_map[k] for k, v in kwargs.items() if v])
password = list(random.choice(chars) for i in range(length))
while True: if special_char:
password = list(random.choice(chars) for i in range(length)) special_num = length // 16 + 1
for k, v in kwargs.items(): special_index = []
if v and not (set(password) & set(args_string_map[k])): for i in range(special_num):
# 没有包含指定的字符, retry index = random.randint(1, length - 1)
break if index not in special_index:
else: special_index.append(index)
if not can_startswith_special_char and password[0] in args_string_map['special_char']: for i in special_index:
# 首位不能为特殊字符, retry password[i] = random.choice(symbols)
continue
else:
# 满足要求终止 while 循环
break
password = ''.join(password) password = ''.join(password)
return password return password

View File

@ -90,4 +90,4 @@ class SendAndVerifyCodeUtil(object):
self.__send_with_email() self.__send_with_email()
cache.set(self.key, self.code, self.timeout) cache.set(self.key, self.code, self.timeout)
logger.info(f'Send verify code to {self.target}: {code}') logger.debug(f'Send verify code to {self.target}')

View File

@ -1,18 +1,17 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from django.utils import translation
from django.utils.translation import gettext_noop
from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.mixins import UserPassesTestMixin
from django.http.response import JsonResponse from django.http.response import JsonResponse
from django.db.models import Model
from django.utils import translation
from rest_framework import permissions from rest_framework import permissions
from rest_framework.request import Request from rest_framework.request import Request
from common.exceptions import UserConfirmRequired from audits.const import ActivityChoices
from common.utils import i18n_fmt
from orgs.utils import current_org
from audits.handler import create_or_update_operate_log from audits.handler import create_or_update_operate_log
from audits.const import ActionChoices, ActivityChoices
from audits.models import ActivityLog from audits.models import ActivityLog
from common.exceptions import UserConfirmRequired
from orgs.utils import current_org
__all__ = [ __all__ = [
"PermissionsMixin", "PermissionsMixin",
@ -49,66 +48,20 @@ class PermissionsMixin(UserPassesTestMixin):
class RecordViewLogMixin: class RecordViewLogMixin:
ACTION = ActionChoices.view model: Model
@staticmethod def record_logs(self, ids, action, detail, model=None, **kwargs):
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
def record_logs(self, ids, **kwargs):
resource_type = self.model._meta.verbose_name
create_or_update_operate_log(
self.ACTION, resource_type, force=True, **kwargs
)
detail = i18n_fmt(
gettext_noop('User %s view/export secret'), self.request.user
)
activities = [
ActivityLog(
resource_id=getattr(resource_id, 'pk', resource_id),
type=ActivityChoices.operate_log, detail=detail, org_id=current_org.id,
)
for resource_id in ids
]
ActivityLog.objects.bulk_create(activities)
def list(self, request, *args, **kwargs):
response = super().list(request, *args, **kwargs)
with translation.override('en'): with translation.override('en'):
resource_display = self.get_resource_display(request) model = model or self.model
ids = [q.id for q in self.get_queryset()] resource_type = model._meta.verbose_name
self.record_logs(ids, resource_display=resource_display) create_or_update_operate_log(
return response action, resource_type, force=True, **kwargs
)
def retrieve(self, request, *args, **kwargs): activities = [
response = super().retrieve(request, *args, **kwargs) ActivityLog(
with translation.override('en'): resource_id=resource_id, type=ActivityChoices.operate_log,
resource = self.get_object() detail=detail, org_id=current_org.id,
self.record_logs([resource.id], resource=resource) )
return response for resource_id in ids
]
ActivityLog.objects.bulk_create(activities)

View File

@ -1,7 +1,7 @@
# #
from django.http import HttpResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.cache import never_cache from django.views.decorators.cache import never_cache
from django.http import HttpResponse
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from common.utils import bulk_get, FlashMessageUtil from common.utils import bulk_get, FlashMessageUtil

View File

@ -1,7 +1,9 @@
import time import time
from collections import defaultdict
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Count, Max, F from django.db.models import Count, Max, F, CharField
from django.db.models.functions import Cast
from django.http.response import JsonResponse, HttpResponse from django.http.response import JsonResponse, HttpResponse
from django.utils import timezone from django.utils import timezone
from django.utils.timesince import timesince from django.utils.timesince import timesince
@ -12,6 +14,7 @@ from rest_framework.views import APIView
from assets.const import AllTypes from assets.const import AllTypes
from assets.models import Asset from assets.models import Asset
from audits.api import OperateLogViewSet
from audits.const import LoginStatusChoices from audits.const import LoginStatusChoices
from audits.models import UserLoginLog, PasswordChangeLog, OperateLog, FTPLog, JobLog from audits.models import UserLoginLog, PasswordChangeLog, OperateLog, FTPLog, JobLog
from common.utils import lazyproperty from common.utils import lazyproperty
@ -52,87 +55,82 @@ class DateTimeMixin:
@lazyproperty @lazyproperty
def date_start_end(self): def date_start_end(self):
return self.days_to_datetime.date(), local_now().date() return self.days_to_datetime.date(), local_now().date() + timezone.timedelta(days=1)
@lazyproperty @lazyproperty
def dates_list(self): def dates_list(self):
now = local_now() return [
dates = [(now - timezone.timedelta(days=i)).date() for i in range(self.days)] (local_now() - timezone.timedelta(days=i)).date()
dates.reverse() for i in range(self.days - 1, -1, -1)
return dates ]
def get_dates_metrics_date(self): def get_dates_metrics_date(self):
dates_metrics_date = [d.strftime('%m-%d') for d in self.dates_list] or ['0'] return [d.strftime('%m-%d') for d in self.dates_list] or ['0']
return dates_metrics_date
@lazyproperty def get_logs_queryset_filter(self, qs, query_field, is_timestamp=False):
def users(self): dt = self.days_to_datetime
return self.org.get_members() t = dt.timestamp() if is_timestamp else dt
query = {f'{query_field}__gte': t}
@lazyproperty return qs.filter(**query)
def sessions_queryset(self):
t = self.days_to_datetime
sessions_queryset = Session.objects.filter(date_start__gte=t)
return sessions_queryset
def get_logs_queryset(self, queryset, query_params): def get_logs_queryset(self, queryset, query_params):
query = {} query = {}
users = self.org.get_members()
if not self.org.is_root(): if not self.org.is_root():
if query_params == 'username': if query_params == 'username':
query = { query = {
f'{query_params}__in': self.users.values_list('username', flat=True) f'{query_params}__in': users.values_list('username', flat=True)
} }
else: else:
query = { query = {
f'{query_params}__in': [str(user) for user in self.users] f'{query_params}__in': [str(user) for user in users]
} }
queryset = queryset.filter(**query) queryset = queryset.filter(**query)
return queryset return queryset
@lazyproperty
def sessions_queryset(self):
return self.get_logs_queryset_filter(Session.objects, 'date_start')
@lazyproperty @lazyproperty
def login_logs_queryset(self): def login_logs_queryset(self):
t = self.days_to_datetime qs = UserLoginLog.objects.all()
queryset = UserLoginLog.objects.filter(datetime__gte=t) qs = self.get_logs_queryset_filter(qs, 'datetime')
queryset = self.get_logs_queryset(queryset, 'username') queryset = self.get_logs_queryset(qs, 'username')
return queryset return queryset
@lazyproperty @lazyproperty
def password_change_logs_queryset(self): def password_change_logs_queryset(self):
t = self.days_to_datetime qs = PasswordChangeLog.objects.all()
queryset = PasswordChangeLog.objects.filter(datetime__gte=t) qs = self.get_logs_queryset_filter(qs, 'datetime')
queryset = self.get_logs_queryset(queryset, 'user') queryset = self.get_logs_queryset(qs, 'user')
return queryset return queryset
@lazyproperty @lazyproperty
def operate_logs_queryset(self): def operate_logs_queryset(self):
from audits.api import OperateLogViewSet qs = OperateLogViewSet().get_queryset()
t = self.days_to_datetime return self.get_logs_queryset_filter(qs, 'datetime')
queryset = OperateLogViewSet().get_queryset().filter(datetime__gte=t)
return queryset
@lazyproperty @lazyproperty
def ftp_logs_queryset(self): def ftp_logs_queryset(self):
t = self.days_to_datetime qs = FTPLog.objects.all()
queryset = FTPLog.objects.filter(date_start__gte=t) qs = self.get_logs_queryset_filter(qs, 'date_start')
queryset = self.get_logs_queryset(queryset, 'user') return self.get_logs_queryset(qs, 'user')
return queryset
@lazyproperty @lazyproperty
def command_queryset(self): def command_queryset(self):
t = self.days_to_datetime qs = Command.objects.all()
t = t.timestamp() return self.get_logs_queryset_filter(qs, 'timestamp', is_timestamp=True)
queryset = Command.objects.filter(timestamp__gte=t)
return queryset
@lazyproperty @lazyproperty
def job_logs_queryset(self): def job_logs_queryset(self):
t = self.days_to_datetime qs = JobLog.objects.all()
queryset = JobLog.objects.filter(date_created__gte=t) return self.get_logs_queryset_filter(qs, 'date_start')
return queryset
class DatesLoginMetricMixin: class DatesLoginMetricMixin:
dates_list: list dates_list: list
date_start_end: tuple
command_queryset: Command.objects command_queryset: Command.objects
sessions_queryset: Session.objects sessions_queryset: Session.objects
ftp_logs_queryset: OperateLog.objects ftp_logs_queryset: OperateLog.objects
@ -141,41 +139,6 @@ class DatesLoginMetricMixin:
operate_logs_queryset: OperateLog.objects operate_logs_queryset: OperateLog.objects
password_change_logs_queryset: PasswordChangeLog.objects password_change_logs_queryset: PasswordChangeLog.objects
def get_dates_metrics_total_count_login(self):
queryset = UserLoginLog.objects \
.filter(datetime__range=(self.date_start_end)) \
.values('datetime__date').annotate(id__count=Count(id)) \
.order_by('datetime__date')
map_date_logincount = {i['datetime__date']: i['id__count'] for i in queryset}
return [map_date_logincount.get(d, 0) for d in self.dates_list]
def get_dates_metrics_total_count_active_users(self):
queryset = Session.objects \
.filter(date_start__range=(self.date_start_end)) \
.values('date_start__date') \
.annotate(id__count=Count('user_id', distinct=True)) \
.order_by('date_start__date')
map_date_usercount = {i['date_start__date']: i['id__count'] for i in queryset}
return [map_date_usercount.get(d, 0) for d in self.dates_list]
def get_dates_metrics_total_count_active_assets(self):
queryset = Session.objects \
.filter(date_start__range=(self.date_start_end)) \
.values('date_start__date') \
.annotate(id__count=Count('asset_id', distinct=True)) \
.order_by('date_start__date')
map_date_assetcount = {i['date_start__date']: i['id__count'] for i in queryset}
return [map_date_assetcount.get(d, 0) for d in self.dates_list]
def get_dates_metrics_total_count_sessions(self):
queryset = Session.objects \
.filter(date_start__range=(self.date_start_end)) \
.values('date_start__date') \
.annotate(id__count=Count(id)) \
.order_by('date_start__date')
map_date_usercount = {i['date_start__date']: i['id__count'] for i in queryset}
return [map_date_usercount.get(d, 0) for d in self.dates_list]
@lazyproperty @lazyproperty
def get_type_to_assets(self): def get_type_to_assets(self):
result = Asset.objects.annotate(type=F('platform__type')). \ result = Asset.objects.annotate(type=F('platform__type')). \
@ -187,30 +150,53 @@ class DatesLoginMetricMixin:
i['label'] = all_types_dict.get(tp, tp) i['label'] = all_types_dict.get(tp, tp)
return result return result
def filter_date_start_end(self, queryset, field_name):
query = {f'{field_name}__range': self.date_start_end}
return queryset.filter(**query)
def get_date_metrics(self, queryset, field_name, count_field):
queryset = self.filter_date_start_end(queryset, field_name)
queryset = queryset.values_list(field_name, count_field)
date_group_map = defaultdict(set)
for datetime, count_field in queryset:
date_str = str(datetime.date())
date_group_map[date_str].add(count_field)
return [
len(date_group_map.get(str(d), set()))
for d in self.dates_list
]
def get_dates_metrics_total_count_login(self):
return self.get_date_metrics(UserLoginLog.objects, 'datetime', 'id')
def get_dates_metrics_total_count_active_users(self):
return self.get_date_metrics(Session.objects, 'date_start', 'user_id')
def get_dates_metrics_total_count_active_assets(self):
return self.get_date_metrics(Session.objects, 'date_start', 'asset_id')
def get_dates_metrics_total_count_sessions(self):
return self.get_date_metrics(Session.objects, 'date_start', 'id')
def get_dates_login_times_assets(self): def get_dates_login_times_assets(self):
assets = self.sessions_queryset.values("asset") \ assets = self.sessions_queryset.values("asset") \
.annotate(total=Count("asset")) \ .annotate(total=Count("asset")) \
.annotate(last=Max("date_start")).order_by("-total") .annotate(last=Cast(Max("date_start"), output_field=CharField())) \
assets = assets[:10] .order_by("-total")
for asset in assets: return list(assets[:10])
asset['last'] = str(asset['last'])
return list(assets)
def get_dates_login_times_users(self): def get_dates_login_times_users(self):
users = self.sessions_queryset.values("user_id") \ users = self.sessions_queryset.values("user_id") \
.annotate(total=Count("user_id")) \ .annotate(total=Count("user_id")) \
.annotate(user=Max('user')) \ .annotate(user=Max('user')) \
.annotate(last=Max("date_start")).order_by("-total") .annotate(last=Cast(Max("date_start"), output_field=CharField())) \
users = users[:10] .order_by("-total")
for user in users: return list(users[:10])
user['last'] = str(user['last'])
return list(users)
def get_dates_login_record_sessions(self): def get_dates_login_record_sessions(self):
sessions = self.sessions_queryset.order_by('-date_start') sessions = self.sessions_queryset.order_by('-date_start')
sessions = sessions[:10]
for session in sessions:
session.avatar_url = User.get_avatar_url("")
sessions = [ sessions = [
{ {
'user': session.user, 'user': session.user,
@ -219,7 +205,7 @@ class DatesLoginMetricMixin:
'date_start': str(session.date_start), 'date_start': str(session.date_start),
'timesince': timesince(session.date_start) 'timesince': timesince(session.date_start)
} }
for session in sessions for session in sessions[:10]
] ]
return sessions return sessions
@ -253,12 +239,13 @@ class DatesLoginMetricMixin:
@lazyproperty @lazyproperty
def job_logs_running_amount(self): def job_logs_running_amount(self):
return self.job_logs_queryset.filter(status__in=[JobStatus.running]).count() return self.job_logs_queryset.filter(status=JobStatus.running).count()
@lazyproperty @lazyproperty
def job_logs_failed_amount(self): def job_logs_failed_amount(self):
return self.job_logs_queryset.filter( return self.job_logs_queryset.filter(
status__in=[JobStatus.failed, JobStatus.timeout]).count() status__in=[JobStatus.failed, JobStatus.timeout]
).count()
@lazyproperty @lazyproperty
def job_logs_amount(self): def job_logs_amount(self):
@ -268,6 +255,10 @@ class DatesLoginMetricMixin:
def sessions_amount(self): def sessions_amount(self):
return self.sessions_queryset.count() return self.sessions_queryset.count()
@lazyproperty
def online_sessions_amount(self):
return self.sessions_queryset.filter(is_finished=False).count()
@lazyproperty @lazyproperty
def ftp_logs_amount(self): def ftp_logs_amount(self):
return self.ftp_logs_queryset.count() return self.ftp_logs_queryset.count()
@ -275,9 +266,9 @@ class DatesLoginMetricMixin:
class IndexApi(DateTimeMixin, DatesLoginMetricMixin, APIView): class IndexApi(DateTimeMixin, DatesLoginMetricMixin, APIView):
http_method_names = ['get'] http_method_names = ['get']
rbac_perms = {
def check_permissions(self, request): 'GET': ['rbac.view_audit | rbac.view_console'],
return request.user.has_perm('rbac.view_audit | rbac.view_console') }
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
data = {} data = {}
@ -365,7 +356,7 @@ class IndexApi(DateTimeMixin, DatesLoginMetricMixin, APIView):
if _all or query_params.get('total_count') or query_params.get('total_count_history_sessions'): if _all or query_params.get('total_count') or query_params.get('total_count_history_sessions'):
data.update({ data.update({
'total_count_history_sessions': self.sessions_amount - caches.total_count_online_sessions, 'total_count_history_sessions': self.sessions_amount - self.online_sessions_amount,
}) })
if _all or query_params.get('total_count') or query_params.get('total_count_ftp_logs'): if _all or query_params.get('total_count') or query_params.get('total_count_ftp_logs'):

View File

@ -333,9 +333,9 @@ class Config(dict):
'CAS_ROOT_PROXIED_AS': 'https://example.com', 'CAS_ROOT_PROXIED_AS': 'https://example.com',
'CAS_LOGOUT_COMPLETELY': True, 'CAS_LOGOUT_COMPLETELY': True,
'CAS_VERSION': 3, 'CAS_VERSION': 3,
'CAS_USERNAME_ATTRIBUTE': 'uid', 'CAS_USERNAME_ATTRIBUTE': 'cas:user',
'CAS_APPLY_ATTRIBUTES_TO_USER': False, 'CAS_APPLY_ATTRIBUTES_TO_USER': False,
'CAS_RENAME_ATTRIBUTES': {'uid': 'username'}, 'CAS_RENAME_ATTRIBUTES': {'cas:user': 'username'},
'CAS_CREATE_USER': True, 'CAS_CREATE_USER': True,
'AUTH_SSO': False, 'AUTH_SSO': False,
@ -382,6 +382,9 @@ class Config(dict):
'AUTH_OAUTH2_USER_ATTR_MAP': { 'AUTH_OAUTH2_USER_ATTR_MAP': {
'name': 'name', 'username': 'username', 'email': 'email' 'name': 'name', 'username': 'username', 'email': 'email'
}, },
'AUTH_PASSKEY': False,
'FIDO_SERVER_ID': '',
'FIDO_SERVER_NAME': 'JumpServer',
# 企业微信 # 企业微信
'AUTH_WECOM': False, 'AUTH_WECOM': False,
@ -457,9 +460,6 @@ class Config(dict):
'TERMINAL_SESSION_KEEP_DURATION': 200, 'TERMINAL_SESSION_KEEP_DURATION': 200,
'TERMINAL_HOST_KEY': '', 'TERMINAL_HOST_KEY': '',
'TERMINAL_COMMAND_STORAGE': {}, 'TERMINAL_COMMAND_STORAGE': {},
# Luna 页面
# 默认图形化分辨率
'TERMINAL_GRAPHICAL_RESOLUTION': 'Auto',
# 未来废弃(目前迁移会用) # 未来废弃(目前迁移会用)
'TERMINAL_RDP_ADDR': '', 'TERMINAL_RDP_ADDR': '',
# 保留(Luna还在用) # 保留(Luna还在用)
@ -578,7 +578,9 @@ class Config(dict):
'FTP_FILE_MAX_STORE': 100, 'FTP_FILE_MAX_STORE': 100,
# API 请求次数限制 # API 请求次数限制
'MAX_LIMIT_PER_PAGE': 100 'MAX_LIMIT_PER_PAGE': 100,
'LIMIT_SUPER_PRIV': False,
} }
old_config_map = { old_config_map = {

Some files were not shown because too many files have changed in this diff Show More