Merge pull request #13059 from jumpserver/dev

v3.10.8
pull/13060/head
Bryan 2024-04-18 17:16:37 +08:00 committed by GitHub
commit 8cf8a3701b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
239 changed files with 13761 additions and 2401 deletions

View File

@ -1,11 +1,35 @@
--- ---
name: 需求建议 name: 需求建议
about: 提出针对本项目的想法和建议 about: 提出针对本项目的想法和建议
title: "[Feature] " title: "[Feature] 需求标题"
labels: 类型:需求 labels: 类型:需求
assignees: assignees:
- ibuler - ibuler
- baijiangjie - baijiangjie
--- ---
**请描述您的需求或者改进建议.** ## 注意
_针对过于简单的需求描述不予考虑。请确保提供足够的细节和信息以支持功能的开发和实现。_
## 功能名称
[在这里输入功能的名称或标题]
## 功能描述
[在这里描述该功能的详细内容,包括其作用、目的和所需的功能]
## 用户故事(可选)
[如果适用,可以提供用户故事来更好地理解该功能的使用场景和用户期望]
## 功能要求
- [要求1描述该功能的具体要求如界面设计、交互逻辑等]
- [要求2描述该功能的另一个具体要求]
- [以此类推,列出所有相关的功能要求]
## 示例或原型(可选)
[如果有的话,提供该功能的示例或原型图以更好地说明功能的实现方式]
## 优先级
[描述该功能的优先级,如高、中、低,或使用数字等其他标识]
## 备注(可选)
[在这里添加任何其他相关信息或备注]

View File

@ -1,22 +1,51 @@
--- ---
name: Bug 提交 name: Bug 提交
about: 提交产品缺陷帮助我们更好的改进 about: 提交产品缺陷帮助我们更好的改进
title: "[Bug] " title: "[Bug] Bug 标题"
labels: 类型:Bug labels: 类型:Bug
assignees: assignees:
- baijiangjie - baijiangjie
--- ---
**JumpServer 版本( v2.28 之前的版本不再支持 )** ## 注意
**JumpServer 版本( v2.28 之前的版本不再支持 )** <br>
_针对过于简单的 Bug 描述不予考虑。请确保提供足够的细节和信息以支持 Bug 的复现和修复。_
## 当前使用的 JumpServer 版本 (必填)
[在这里输入当前使用的 JumpServer 的版本号]
## 使用的版本类型 (必填)
- [ ] 社区版
- [ ] 企业版
- [ ] 企业试用版
**浏览器版本** ## 版本安装方式 (必填)
- [ ] 在线安装 (一键命令)
- [ ] 离线安装 (下载离线包)
- [ ] All-in-One
- [ ] 1Panel 安装
- [ ] Kubernetes 安装
- [ ] 源码安装
## Bug 描述 (详细)
[在这里描述 Bug 的详细情况,包括其影响和出现的具体情况]
**Bug 描述** ## 复现步骤
1. [描述如何复现 Bug 的第一步]
2. [描述如何复现 Bug 的第二步]
3. [以此类推,列出所有复现 Bug 所需的步骤]
## 期望行为
[描述 Bug 出现时期望的系统行为或结果]
**Bug 重现步骤(有截图更好)** ## 实际行为
1. [描述实际上发生了什么,以及 Bug 出现的具体情况]
2.
3. ## 系统环境
- 操作系统:[例如Windows 10, macOS Big Sur]
- 浏览器/应用版本:[如果适用,请提供相关版本信息]
- 其他相关环境信息:[如果有其他相关环境信息,请在此处提供]
## 附加信息(可选)
[在这里添加任何其他相关信息,如截图、错误信息等]

View File

@ -1,10 +1,50 @@
--- ---
name: 问题咨询 name: 问题咨询
about: 提出针对本项目安装部署、使用及其他方面的相关问题 about: 提出针对本项目安装部署、使用及其他方面的相关问题
title: "[Question] " title: "[Question] 问题标题"
labels: 类型:提问 labels: 类型:提问
assignees: assignees:
- baijiangjie - baijiangjie
--- ---
## 注意
**请描述您的问题.** <br>
**JumpServer 版本( v2.28 之前的版本不再支持 )** <br>
_针对过于简单的 Bug 描述不予考虑。请确保提供足够的细节和信息以支持 Bug 的复现和修复。_
## 当前使用的 JumpServer 版本 (必填)
[在这里输入当前使用的 JumpServer 的版本号]
## 使用的版本类型 (必填)
- [ ] 社区版
- [ ] 企业版
- [ ] 企业试用版
## 版本安装方式 (必填)
- [ ] 在线安装 (一键命令)
- [ ] 离线安装 (下载离线包)
- [ ] All-in-One
- [ ] 1Panel 安装
- [ ] Kubernetes 安装
- [ ] 源码安装
## 问题描述 (详细)
[在这里描述你遇到的问题]
## 背景信息
- 操作系统:[例如Windows 10, macOS Big Sur]
- 浏览器/应用版本:[如果适用,请提供相关版本信息]
- 其他相关环境信息:[如果有其他相关环境信息,请在此处提供]
## 具体问题
[在这里详细描述你的问题,包括任何相关细节或错误信息]
## 尝试过的解决方法
[如果你已经尝试过解决问题,请在这里列出你已经尝试过的解决方法]
## 预期结果
[描述你期望的解决方案或结果]
## 我们的期望
[描述你希望我们提供的帮助或支持]
**请描述您的问题.**

1
.gitignore vendored
View File

@ -43,3 +43,4 @@ releashe
data/* data/*
test.py test.py
.history/ .history/
.test/

View File

@ -111,8 +111,17 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=core-apt \
&& sed -i "s@# export @export @g" ~/.bashrc \ && sed -i "s@# export @export @g" ~/.bashrc \
&& sed -i "s@# alias @alias @g" ~/.bashrc && sed -i "s@# alias @alias @g" ~/.bashrc
ARG RECEPTOR_VERSION=v1.4.5
RUN set -ex \
&& wget -O /opt/receptor.tar.gz https://github.com/ansible/receptor/releases/download/${RECEPTOR_VERSION}/receptor_${RECEPTOR_VERSION/v/}_linux_${TARGETARCH}.tar.gz \
&& tar -xf /opt/receptor.tar.gz -C /usr/local/bin/ \
&& chown root:root /usr/local/bin/receptor \
&& chmod 755 /usr/local/bin/receptor \
&& rm -f /opt/receptor.tar.gz
COPY --from=stage-2 /opt/py3 /opt/py3 COPY --from=stage-2 /opt/py3 /opt/py3
COPY --from=stage-1 /opt/jumpserver/release/jumpserver /opt/jumpserver COPY --from=stage-1 /opt/jumpserver/release/jumpserver /opt/jumpserver
COPY --from=stage-1 /opt/jumpserver/release/jumpserver/apps/libs/ansible/ansible.cfg /etc/ansible/
WORKDIR /opt/jumpserver WORKDIR /opt/jumpserver

View File

@ -85,7 +85,7 @@ If you find a security problem, please contact us directly
- 400-052-0755 - 400-052-0755
### License & Copyright ### License & Copyright
Copyright (c) 2014-2022 FIT2CLOUD Tech, Inc., All rights reserved. Copyright (c) 2014-2024 FIT2CLOUD Tech, Inc., All rights reserved.
Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

View File

@ -18,9 +18,8 @@ __all__ = [
class AccountBackupPlanViewSet(OrgBulkModelViewSet): class AccountBackupPlanViewSet(OrgBulkModelViewSet):
model = AccountBackupAutomation model = AccountBackupAutomation
filter_fields = ('name',) filterset_fields = ('name',)
search_fields = filter_fields search_fields = filterset_fields
ordering = ('name',)
serializer_class = serializers.AccountBackupSerializer serializer_class = serializers.AccountBackupSerializer

View File

@ -20,8 +20,8 @@ __all__ = [
class AutomationAssetsListApi(generics.ListAPIView): class AutomationAssetsListApi(generics.ListAPIView):
model = BaseAutomation model = BaseAutomation
serializer_class = serializers.AutomationAssetsSerializer serializer_class = serializers.AutomationAssetsSerializer
filter_fields = ("name", "address") filterset_fields = ("name", "address")
search_fields = filter_fields search_fields = filterset_fields
def get_object(self): def get_object(self):
pk = self.kwargs.get('pk') pk = self.kwargs.get('pk')

View File

@ -6,9 +6,12 @@ from rest_framework.response import Response
from accounts import serializers from accounts import serializers
from accounts.const import AutomationTypes from accounts.const import AutomationTypes
from accounts.filters import ChangeSecretRecordFilterSet
from accounts.models import ChangeSecretAutomation, ChangeSecretRecord from accounts.models import ChangeSecretAutomation, ChangeSecretRecord
from accounts.tasks import execute_automation_record_task from accounts.tasks import execute_automation_record_task
from authentication.permissions import UserConfirmation, ConfirmType
from orgs.mixins.api import OrgBulkModelViewSet, OrgGenericViewSet from orgs.mixins.api import OrgBulkModelViewSet, OrgGenericViewSet
from rbac.permissions import RBACPermission
from .base import ( from .base import (
AutomationAssetsListApi, AutomationRemoveAssetApi, AutomationAddAssetApi, AutomationAssetsListApi, AutomationRemoveAssetApi, AutomationAddAssetApi,
AutomationNodeAddRemoveApi, AutomationExecutionViewSet AutomationNodeAddRemoveApi, AutomationExecutionViewSet
@ -24,35 +27,54 @@ __all__ = [
class ChangeSecretAutomationViewSet(OrgBulkModelViewSet): class ChangeSecretAutomationViewSet(OrgBulkModelViewSet):
model = ChangeSecretAutomation model = ChangeSecretAutomation
filter_fields = ('name', 'secret_type', 'secret_strategy') filterset_fields = ('name', 'secret_type', 'secret_strategy')
search_fields = filter_fields search_fields = filterset_fields
serializer_class = serializers.ChangeSecretAutomationSerializer serializer_class = serializers.ChangeSecretAutomationSerializer
class ChangeSecretRecordViewSet(mixins.ListModelMixin, OrgGenericViewSet): class ChangeSecretRecordViewSet(mixins.ListModelMixin, OrgGenericViewSet):
serializer_class = serializers.ChangeSecretRecordSerializer filterset_class = ChangeSecretRecordFilterSet
filterset_fields = ('asset_id', 'execution_id')
search_fields = ('asset__address',) search_fields = ('asset__address',)
tp = AutomationTypes.change_secret tp = AutomationTypes.change_secret
serializer_classes = {
'default': serializers.ChangeSecretRecordSerializer,
'secret': serializers.ChangeSecretRecordViewSecretSerializer,
}
rbac_perms = { rbac_perms = {
'execute': 'accounts.add_changesecretexecution', 'execute': 'accounts.add_changesecretexecution',
'secret': 'accounts.view_changesecretrecord',
} }
def get_permissions(self):
if self.action == 'secret':
self.permission_classes = [
RBACPermission,
UserConfirmation.require(ConfirmType.MFA)
]
return super().get_permissions()
def get_queryset(self): def get_queryset(self):
return ChangeSecretRecord.objects.all() return ChangeSecretRecord.objects.all()
@action(methods=['post'], detail=False, url_path='execute') @action(methods=['post'], detail=False, url_path='execute')
def execute(self, request, *args, **kwargs): def execute(self, request, *args, **kwargs):
record_id = request.data.get('record_id') record_ids = request.data.get('record_ids')
record = self.get_queryset().filter(pk=record_id) records = self.get_queryset().filter(id__in=record_ids)
if not record: execution_count = records.values_list('execution_id', flat=True).distinct().count()
if execution_count != 1:
return Response( return Response(
{'detail': 'record not found'}, {'detail': 'Only one execution is allowed to execute'},
status=status.HTTP_404_NOT_FOUND status=status.HTTP_400_BAD_REQUEST
) )
task = execute_automation_record_task.delay(record_id, self.tp) task = execute_automation_record_task.delay(record_ids, self.tp)
return Response({'task': task.id}, status=status.HTTP_200_OK) return Response({'task': task.id}, status=status.HTTP_200_OK)
@action(methods=['get'], detail=True, url_path='secret')
def secret(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
return Response(serializer.data)
class ChangSecretExecutionViewSet(AutomationExecutionViewSet): class ChangSecretExecutionViewSet(AutomationExecutionViewSet):
rbac_perms = ( rbac_perms = (

View File

@ -20,8 +20,8 @@ __all__ = [
class GatherAccountsAutomationViewSet(OrgBulkModelViewSet): class GatherAccountsAutomationViewSet(OrgBulkModelViewSet):
model = GatherAccountsAutomation model = GatherAccountsAutomation
filter_fields = ('name',) filterset_fields = ('name',)
search_fields = filter_fields search_fields = filterset_fields
serializer_class = serializers.GatherAccountAutomationSerializer serializer_class = serializers.GatherAccountAutomationSerializer

View File

@ -20,8 +20,8 @@ __all__ = [
class PushAccountAutomationViewSet(OrgBulkModelViewSet): class PushAccountAutomationViewSet(OrgBulkModelViewSet):
model = PushAccountAutomation model = PushAccountAutomation
filter_fields = ('name', 'secret_type', 'secret_strategy') filterset_fields = ('name', 'secret_type', 'secret_strategy')
search_fields = filter_fields search_fields = filterset_fields
serializer_class = serializers.PushAccountAutomationSerializer serializer_class = serializers.PushAccountAutomationSerializer

View File

@ -6,7 +6,7 @@ from django.conf import settings
from rest_framework import serializers from rest_framework import serializers
from xlsxwriter import Workbook from xlsxwriter import Workbook
from accounts.const.automation import AccountBackupType from accounts.const import AccountBackupType
from accounts.models.automations.backup_account import AccountBackupAutomation from accounts.models.automations.backup_account import AccountBackupAutomation
from accounts.notifications import AccountBackupExecutionTaskMsg, AccountBackupByObjStorageExecutionTaskMsg from accounts.notifications import AccountBackupExecutionTaskMsg, AccountBackupByObjStorageExecutionTaskMsg
from accounts.serializers import AccountSecretSerializer from accounts.serializers import AccountSecretSerializer

View File

@ -18,6 +18,8 @@
become_user: "{{ custom_become_user | default('') }}" become_user: "{{ custom_become_user | default('') }}"
become_password: "{{ custom_become_password | default('') }}" become_password: "{{ custom_become_password | default('') }}"
become_private_key_path: "{{ custom_become_private_key_path | default(None) }}" become_private_key_path: "{{ custom_become_private_key_path | default(None) }}"
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}"
register: ping_info register: ping_info
delegate_to: localhost delegate_to: localhost
@ -54,4 +56,6 @@
become_user: "{{ account.become.ansible_user | default('') }}" become_user: "{{ account.become.ansible_user | default('') }}"
become_password: "{{ account.become.ansible_password | default('') }}" become_password: "{{ account.become.ansible_password | default('') }}"
become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}"
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}"
delegate_to: localhost delegate_to: localhost

View File

@ -85,6 +85,7 @@
become_user: "{{ account.become.ansible_user | default('') }}" become_user: "{{ account.become.ansible_user | default('') }}"
become_password: "{{ account.become.ansible_password | default('') }}" become_password: "{{ account.become.ansible_password | default('') }}"
become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}"
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
when: account.secret_type == "password" when: account.secret_type == "password"
delegate_to: localhost delegate_to: localhost
@ -95,5 +96,6 @@
login_user: "{{ account.username }}" login_user: "{{ account.username }}"
login_private_key_path: "{{ account.private_key_path }}" login_private_key_path: "{{ account.private_key_path }}"
gateway_args: "{{ jms_asset.ansible_ssh_common_args | default('') }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default('') }}"
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
when: account.secret_type == "ssh_key" when: account.secret_type == "ssh_key"
delegate_to: localhost delegate_to: localhost

View File

@ -85,6 +85,7 @@
become_user: "{{ account.become.ansible_user | default('') }}" become_user: "{{ account.become.ansible_user | default('') }}"
become_password: "{{ account.become.ansible_password | default('') }}" become_password: "{{ account.become.ansible_password | default('') }}"
become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}"
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
when: account.secret_type == "password" when: account.secret_type == "password"
delegate_to: localhost delegate_to: localhost
@ -95,5 +96,6 @@
login_user: "{{ account.username }}" login_user: "{{ account.username }}"
login_private_key_path: "{{ account.private_key_path }}" login_private_key_path: "{{ account.private_key_path }}"
gateway_args: "{{ jms_asset.ansible_ssh_common_args | default('') }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default('') }}"
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
when: account.secret_type == "ssh_key" when: account.secret_type == "ssh_key"
delegate_to: localhost delegate_to: localhost

View File

@ -7,9 +7,9 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from xlsxwriter import Workbook from xlsxwriter import Workbook
from accounts.const import AutomationTypes, SecretType, SSHKeyStrategy, SecretStrategy from accounts.const import AutomationTypes, SecretType, SSHKeyStrategy, SecretStrategy, ChangeSecretRecordStatusChoice
from accounts.models import ChangeSecretRecord from accounts.models import ChangeSecretRecord
from accounts.notifications import ChangeSecretExecutionTaskMsg from accounts.notifications import ChangeSecretExecutionTaskMsg, ChangeSecretFailedMsg
from accounts.serializers import ChangeSecretRecordBackUpSerializer from accounts.serializers import ChangeSecretRecordBackUpSerializer
from assets.const import HostTypes from assets.const import HostTypes
from common.utils import get_logger from common.utils import get_logger
@ -27,7 +27,7 @@ class ChangeSecretManager(AccountBasePlaybookManager):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.record_id = self.execution.snapshot.get('record_id') self.record_map = self.execution.snapshot.get('record_map', {})
self.secret_type = self.execution.snapshot.get('secret_type') self.secret_type = self.execution.snapshot.get('secret_type')
self.secret_strategy = self.execution.snapshot.get( self.secret_strategy = self.execution.snapshot.get(
'secret_strategy', SecretStrategy.custom 'secret_strategy', SecretStrategy.custom
@ -123,14 +123,20 @@ class ChangeSecretManager(AccountBasePlaybookManager):
print(f'new_secret is None, account: {account}') print(f'new_secret is None, account: {account}')
continue continue
if self.record_id is None: asset_account_id = f'{asset.id}-{account.id}'
if asset_account_id not in self.record_map:
recorder = ChangeSecretRecord( recorder = ChangeSecretRecord(
asset=asset, account=account, execution=self.execution, asset=asset, account=account, execution=self.execution,
old_secret=account.secret, new_secret=new_secret, old_secret=account.secret, new_secret=new_secret,
) )
records.append(recorder) records.append(recorder)
else: else:
recorder = ChangeSecretRecord.objects.get(id=self.record_id) record_id = self.record_map[asset_account_id]
try:
recorder = ChangeSecretRecord.objects.get(id=record_id)
except ChangeSecretRecord.DoesNotExist:
print(f"Record {record_id} not found")
continue
self.name_recorder_mapper[h['name']] = recorder self.name_recorder_mapper[h['name']] = recorder
@ -158,25 +164,43 @@ class ChangeSecretManager(AccountBasePlaybookManager):
recorder = self.name_recorder_mapper.get(host) recorder = self.name_recorder_mapper.get(host)
if not recorder: if not recorder:
return return
recorder.status = 'success' recorder.status = ChangeSecretRecordStatusChoice.success.value
recorder.date_finished = timezone.now() recorder.date_finished = timezone.now()
recorder.save()
account = recorder.account account = recorder.account
if not account: if not account:
print("Account not found, deleted ?") print("Account not found, deleted ?")
return return
account.secret = recorder.new_secret account.secret = recorder.new_secret
account.date_updated = timezone.now() account.date_updated = timezone.now()
account.save(update_fields=['secret', 'date_updated'])
max_retries = 3
retry_count = 0
while retry_count < max_retries:
try:
recorder.save()
account.save(update_fields=['secret', 'version', 'date_updated'])
break
except Exception as e:
retry_count += 1
if retry_count == max_retries:
self.on_host_error(host, str(e), result)
else:
print(f'retry {retry_count} times for {host} recorder save error: {e}')
time.sleep(1)
def on_host_error(self, host, error, result): def on_host_error(self, host, error, result):
recorder = self.name_recorder_mapper.get(host) recorder = self.name_recorder_mapper.get(host)
if not recorder: if not recorder:
return return
recorder.status = 'failed' recorder.status = ChangeSecretRecordStatusChoice.failed.value
recorder.date_finished = timezone.now() recorder.date_finished = timezone.now()
recorder.error = error recorder.error = error
recorder.save() try:
recorder.save()
except Exception as e:
print(f"\033[31m Save {host} recorder error: {e} \033[0m\n")
def on_runner_failed(self, runner, e): def on_runner_failed(self, runner, e):
logger.error("Account error: ", e) logger.error("Account error: ", e)
@ -192,7 +216,7 @@ class ChangeSecretManager(AccountBasePlaybookManager):
def get_summary(recorders): def get_summary(recorders):
total, succeed, failed = 0, 0, 0 total, succeed, failed = 0, 0, 0
for recorder in recorders: for recorder in recorders:
if recorder.status == 'success': if recorder.status == ChangeSecretRecordStatusChoice.success.value:
succeed += 1 succeed += 1
else: else:
failed += 1 failed += 1
@ -209,18 +233,35 @@ class ChangeSecretManager(AccountBasePlaybookManager):
summary = self.get_summary(recorders) summary = self.get_summary(recorders)
print(summary, end='') print(summary, end='')
if self.record_id: if self.record_map:
return return
self.send_recorder_mail(recorders, summary) failed_recorders = [
r for r in recorders
if r.status == ChangeSecretRecordStatusChoice.failed.value
]
def send_recorder_mail(self, recorders, summary):
recipients = self.execution.recipients recipients = self.execution.recipients
if not recorders or not recipients: recipients = User.objects.filter(id__in=list(recipients.keys()))
if not recipients:
return return
recipients = User.objects.filter(id__in=list(recipients.keys())) if failed_recorders:
name = self.execution.snapshot.get('name')
execution_id = str(self.execution.id)
_ids = [r.id for r in failed_recorders]
asset_account_errors = ChangeSecretRecord.objects.filter(
id__in=_ids).values_list('asset__name', 'account__username', 'error')
for user in recipients:
ChangeSecretFailedMsg(name, execution_id, user, asset_account_errors).publish()
if not recorders:
return
self.send_recorder_mail(recipients, recorders, summary)
def send_recorder_mail(self, recipients, recorders, summary):
name = self.execution.snapshot['name'] name = self.execution.snapshot['name']
path = os.path.join(os.path.dirname(settings.BASE_DIR), 'tmp') path = os.path.join(os.path.dirname(settings.BASE_DIR), 'tmp')
filename = os.path.join(path, f'{name}-{local_now_filename()}-{time.time()}.xlsx') filename = os.path.join(path, f'{name}-{local_now_filename()}-{time.time()}.xlsx')

View File

@ -58,7 +58,7 @@ class GatherAccountsManager(AccountBasePlaybookManager):
result = self.filter_success_result(asset.type, info) result = self.filter_success_result(asset.type, info)
self.collect_asset_account_info(asset, result) self.collect_asset_account_info(asset, result)
else: else:
logger.error(f'Not found {host} info') print(f'\033[31m Not found {host} info \033[0m\n')
def update_or_create_accounts(self): def update_or_create_accounts(self):
for asset, data in self.asset_account_info.items(): for asset, data in self.asset_account_info.items():

View File

@ -85,6 +85,7 @@
become_user: "{{ account.become.ansible_user | default('') }}" become_user: "{{ account.become.ansible_user | default('') }}"
become_password: "{{ account.become.ansible_password | default('') }}" become_password: "{{ account.become.ansible_password | default('') }}"
become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}"
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
when: account.secret_type == "password" when: account.secret_type == "password"
delegate_to: localhost delegate_to: localhost
@ -95,6 +96,7 @@
login_user: "{{ account.username }}" login_user: "{{ account.username }}"
login_private_key_path: "{{ account.private_key_path }}" login_private_key_path: "{{ account.private_key_path }}"
gateway_args: "{{ jms_asset.ansible_ssh_common_args | default('') }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default('') }}"
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
when: account.secret_type == "ssh_key" when: account.secret_type == "ssh_key"
delegate_to: localhost delegate_to: localhost

View File

@ -85,6 +85,7 @@
become_user: "{{ account.become.ansible_user | default('') }}" become_user: "{{ account.become.ansible_user | default('') }}"
become_password: "{{ account.become.ansible_password | default('') }}" become_password: "{{ account.become.ansible_password | default('') }}"
become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}"
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
when: account.secret_type == "password" when: account.secret_type == "password"
delegate_to: localhost delegate_to: localhost
@ -95,6 +96,7 @@
login_user: "{{ account.username }}" login_user: "{{ account.username }}"
login_private_key_path: "{{ account.private_key_path }}" login_private_key_path: "{{ account.private_key_path }}"
gateway_args: "{{ jms_asset.ansible_ssh_common_args | default('') }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default('') }}"
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
when: account.secret_type == "ssh_key" when: account.secret_type == "ssh_key"
delegate_to: localhost delegate_to: localhost

View File

@ -60,8 +60,11 @@ class RemoveAccountManager(AccountBasePlaybookManager):
if not tuple_asset_gather_account: if not tuple_asset_gather_account:
return return
asset, gather_account = tuple_asset_gather_account asset, gather_account = tuple_asset_gather_account
Account.objects.filter( try:
asset_id=asset.id, Account.objects.filter(
username=gather_account.username asset_id=asset.id,
).delete() username=gather_account.username
gather_account.delete() ).delete()
gather_account.delete()
except Exception as e:
print(f'\033[31m Delete account {gather_account.username} failed: {e} \033[0m\n')

View File

@ -3,6 +3,7 @@
vars: vars:
ansible_shell_type: sh ansible_shell_type: sh
ansible_connection: local ansible_connection: local
ansible_python_interpreter: /opt/py3/bin/python
tasks: tasks:
- name: Verify account (pyfreerdp) - name: Verify account (pyfreerdp)

View File

@ -19,3 +19,5 @@
become_user: "{{ account.become.ansible_user | default('') }}" become_user: "{{ account.become.ansible_user | default('') }}"
become_password: "{{ account.become.ansible_password | default('') }}" become_password: "{{ account.become.ansible_password | default('') }}"
become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}"
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}"

View File

@ -76,8 +76,14 @@ class VerifyAccountManager(AccountBasePlaybookManager):
def on_host_success(self, host, result): def on_host_success(self, host, result):
account = self.host_account_mapper.get(host) account = self.host_account_mapper.get(host)
account.set_connectivity(Connectivity.OK) try:
account.set_connectivity(Connectivity.OK)
except Exception as e:
print(f'\033[31m Update account {account.name} connectivity failed: {e} \033[0m\n')
def on_host_error(self, host, error, result): def on_host_error(self, host, error, result):
account = self.host_account_mapper.get(host) account = self.host_account_mapper.get(host)
account.set_connectivity(Connectivity.ERR) try:
account.set_connectivity(Connectivity.ERR)
except Exception as e:
print(f'\033[31m Update account {account.name} connectivity failed: {e} \033[0m\n')

View File

@ -15,6 +15,7 @@ class AliasAccount(TextChoices):
INPUT = '@INPUT', _('Manual input') INPUT = '@INPUT', _('Manual input')
USER = '@USER', _('Dynamic user') USER = '@USER', _('Dynamic user')
ANON = '@ANON', _('Anonymous account') ANON = '@ANON', _('Anonymous account')
SPEC = '@SPEC', _('Specified account')
@classmethod @classmethod
def virtual_choices(cls): def virtual_choices(cls):

View File

@ -16,7 +16,7 @@ DEFAULT_PASSWORD_RULES = {
__all__ = [ __all__ = [
'AutomationTypes', 'SecretStrategy', 'SSHKeyStrategy', 'Connectivity', 'AutomationTypes', 'SecretStrategy', 'SSHKeyStrategy', 'Connectivity',
'DEFAULT_PASSWORD_LENGTH', 'DEFAULT_PASSWORD_RULES', 'TriggerChoice', 'DEFAULT_PASSWORD_LENGTH', 'DEFAULT_PASSWORD_RULES', 'TriggerChoice',
'PushAccountActionChoice', 'AccountBackupType' 'PushAccountActionChoice', 'AccountBackupType', 'ChangeSecretRecordStatusChoice',
] ]
@ -103,3 +103,9 @@ class AccountBackupType(models.TextChoices):
email = 'email', _('Email') email = 'email', _('Email')
# 目前只支持sftp方式 # 目前只支持sftp方式
object_storage = 'object_storage', _('SFTP') object_storage = 'object_storage', _('SFTP')
class ChangeSecretRecordStatusChoice(models.TextChoices):
failed = 'failed', _('Failed')
success = 'success', _('Success')
pending = 'pending', _('Pending')

View File

@ -5,7 +5,7 @@ from django_filters import rest_framework as drf_filters
from assets.models import Node from assets.models import Node
from common.drf.filters import BaseFilterSet from common.drf.filters import BaseFilterSet
from .models import Account, GatheredAccount from .models import Account, GatheredAccount, ChangeSecretRecord
class AccountFilterSet(BaseFilterSet): class AccountFilterSet(BaseFilterSet):
@ -61,3 +61,13 @@ class GatheredAccountFilterSet(BaseFilterSet):
class Meta: class Meta:
model = GatheredAccount model = GatheredAccount
fields = ['id', 'username'] fields = ['id', 'username']
class ChangeSecretRecordFilterSet(BaseFilterSet):
asset_name = drf_filters.CharFilter(field_name='asset__name', lookup_expr='icontains')
account_username = drf_filters.CharFilter(field_name='account__username', lookup_expr='icontains')
execution_id = drf_filters.CharFilter(field_name='execution_id', lookup_expr='exact')
class Meta:
model = ChangeSecretRecord
fields = ['id', 'status', 'asset_id', 'execution']

View File

@ -1,8 +1,9 @@
# Generated by Django 4.1.10 on 2023-08-01 09:12 # Generated by Django 4.1.10 on 2023-08-01 09:12
from django.db import migrations, models
import uuid import uuid
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -20,7 +21,7 @@ class Migration(migrations.Migration):
('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')),
('alias', models.CharField(choices=[('@INPUT', 'Manual input'), ('@USER', 'Dynamic user'), ('@ANON', 'Anonymous account')], max_length=128, verbose_name='Alias')), ('alias', models.CharField(choices=[('@INPUT', 'Manual input'), ('@USER', 'Dynamic user'), ('@ANON', 'Anonymous account'), ('@SPEC', 'Specified account')], max_length=128, verbose_name='Alias')),
('secret_from_login', models.BooleanField(default=None, null=True, verbose_name='Secret from login')), ('secret_from_login', models.BooleanField(default=None, null=True, verbose_name='Secret from login')),
], ],
options={ options={

View File

@ -8,7 +8,7 @@ from django.db import models
from django.db.models import F from django.db.models import F
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from accounts.const.automation import AccountBackupType from accounts.const import AccountBackupType
from common.const.choices import Trigger from common.const.choices import Trigger
from common.db import fields from common.db import fields
from common.db.encoder import ModelJSONFieldEncoder from common.db.encoder import ModelJSONFieldEncoder

View File

@ -2,7 +2,7 @@ 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 AutomationTypes, ChangeSecretRecordStatusChoice
) )
from common.db import fields from common.db import fields
from common.db.models import JMSBaseModel from common.db.models import JMSBaseModel
@ -40,7 +40,10 @@ class ChangeSecretRecord(JMSBaseModel):
new_secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('New secret')) new_secret = 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(max_length=16, default='pending', verbose_name=_('Status')) status = models.CharField(
max_length=16, verbose_name=_('Status'),
default=ChangeSecretRecordStatusChoice.pending.value
)
error = models.TextField(blank=True, null=True, verbose_name=_('Error')) error = models.TextField(blank=True, null=True, verbose_name=_('Error'))
class Meta: class Meta:

View File

@ -137,16 +137,13 @@ class BaseAccount(VaultModelMixin, JMSOrgBaseModel):
else: else:
return None return None
@property def get_private_key_path(self, path):
def private_key_path(self):
if self.secret_type != SecretType.SSH_KEY \ if self.secret_type != SecretType.SSH_KEY \
or not self.secret \ or not self.secret \
or not self.private_key: or not self.private_key:
return None return None
project_dir = settings.PROJECT_DIR
tmp_dir = os.path.join(project_dir, 'tmp')
key_name = '.' + md5(self.private_key.encode('utf-8')).hexdigest() key_name = '.' + md5(self.private_key.encode('utf-8')).hexdigest()
key_path = os.path.join(tmp_dir, key_name) key_path = os.path.join(path, key_name)
if not os.path.exists(key_path): if not os.path.exists(key_path):
# https://github.com/ansible/ansible-runner/issues/544 # https://github.com/ansible/ansible-runner/issues/544
# ssh requires OpenSSH format keys to have a full ending newline. # ssh requires OpenSSH format keys to have a full ending newline.
@ -158,6 +155,12 @@ class BaseAccount(VaultModelMixin, JMSOrgBaseModel):
os.chmod(key_path, 0o400) os.chmod(key_path, 0o400)
return key_path return key_path
@property
def private_key_path(self):
project_dir = settings.PROJECT_DIR
tmp_dir = os.path.join(project_dir, 'tmp')
return self.get_private_key_path(tmp_dir)
def get_private_key(self): def get_private_key(self):
if not self.private_key: if not self.private_key:
return None return None

View File

@ -1,6 +1,7 @@
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from accounts.models import ChangeSecretRecord
from common.tasks import send_mail_attachment_async, upload_backup_to_obj_storage from common.tasks import send_mail_attachment_async, upload_backup_to_obj_storage
from notifications.notifications import UserMessage from notifications.notifications import UserMessage
from terminal.models.component.storage import ReplayStorage from terminal.models.component.storage import ReplayStorage
@ -98,3 +99,35 @@ class GatherAccountChangeMsg(UserMessage):
def gen_test_msg(cls): def gen_test_msg(cls):
user = User.objects.first() user = User.objects.first()
return cls(user, {}) return cls(user, {})
class ChangeSecretFailedMsg(UserMessage):
subject = _('Change secret or push account failed information')
def __init__(self, name, execution_id, user, asset_account_errors: list):
self.name = name
self.execution_id = execution_id
self.asset_account_errors = asset_account_errors
super().__init__(user)
def get_html_msg(self) -> dict:
context = {
'name': self.name,
'recipient': self.user,
'execution_id': self.execution_id,
'asset_account_errors': self.asset_account_errors
}
message = render_to_string('accounts/change_secret_failed_info.html', context)
return {
'subject': str(self.subject),
'message': message
}
@classmethod
def gen_test_msg(cls):
name = 'test'
user = User.objects.first()
record = ChangeSecretRecord.objects.first()
execution_id = str(record.execution_id)
return cls(name, execution_id, user, [])

View File

@ -21,6 +21,7 @@ __all__ = [
class BaseAutomationSerializer(PeriodTaskSerializerMixin, BulkOrgResourceModelSerializer): class BaseAutomationSerializer(PeriodTaskSerializerMixin, BulkOrgResourceModelSerializer):
assets = ObjectRelatedField(many=True, required=False, queryset=Asset.objects, label=_('Assets')) assets = ObjectRelatedField(many=True, required=False, queryset=Asset.objects, label=_('Assets'))
nodes = ObjectRelatedField(many=True, required=False, queryset=Node.objects, label=_('Nodes')) nodes = ObjectRelatedField(many=True, required=False, queryset=Node.objects, label=_('Nodes'))
is_periodic = serializers.BooleanField(default=False, required=False, label=_("Periodic perform"))
class Meta: class Meta:
read_only_fields = [ read_only_fields = [

View File

@ -4,7 +4,8 @@ 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, SecretType, SecretStrategy, SSHKeyStrategy AutomationTypes, SecretType, SecretStrategy,
SSHKeyStrategy, ChangeSecretRecordStatusChoice
) )
from accounts.models import ( from accounts.models import (
Account, ChangeSecretAutomation, Account, ChangeSecretAutomation,
@ -21,6 +22,7 @@ logger = get_logger(__file__)
__all__ = [ __all__ = [
'ChangeSecretAutomationSerializer', 'ChangeSecretAutomationSerializer',
'ChangeSecretRecordSerializer', 'ChangeSecretRecordSerializer',
'ChangeSecretRecordViewSecretSerializer',
'ChangeSecretRecordBackUpSerializer', 'ChangeSecretRecordBackUpSerializer',
'ChangeSecretUpdateAssetSerializer', 'ChangeSecretUpdateAssetSerializer',
'ChangeSecretUpdateNodeSerializer', 'ChangeSecretUpdateNodeSerializer',
@ -104,7 +106,10 @@ class ChangeSecretAutomationSerializer(AuthValidateMixin, BaseAutomationSerializ
class ChangeSecretRecordSerializer(serializers.ModelSerializer): class ChangeSecretRecordSerializer(serializers.ModelSerializer):
is_success = serializers.SerializerMethodField(label=_('Is success')) is_success = serializers.SerializerMethodField(label=_('Is success'))
asset = ObjectRelatedField(queryset=Asset.objects, label=_('Asset')) asset = ObjectRelatedField(queryset=Asset.objects, label=_('Asset'))
account = ObjectRelatedField(queryset=Account.objects, label=_('Account')) account = ObjectRelatedField(
queryset=Account.objects, label=_('Account'),
attrs=("id", "name", "username")
)
execution = ObjectRelatedField( execution = ObjectRelatedField(
queryset=AutomationExecution.objects, label=_('Automation task execution') queryset=AutomationExecution.objects, label=_('Automation task execution')
) )
@ -119,7 +124,16 @@ class ChangeSecretRecordSerializer(serializers.ModelSerializer):
@staticmethod @staticmethod
def get_is_success(obj): def get_is_success(obj):
return obj.status == 'success' return obj.status == ChangeSecretRecordStatusChoice.success.value
class ChangeSecretRecordViewSecretSerializer(serializers.ModelSerializer):
class Meta:
model = ChangeSecretRecord
fields = [
'id', 'old_secret', 'new_secret',
]
read_only_fields = fields
class ChangeSecretRecordBackUpSerializer(serializers.ModelSerializer): class ChangeSecretRecordBackUpSerializer(serializers.ModelSerializer):
@ -145,7 +159,7 @@ class ChangeSecretRecordBackUpSerializer(serializers.ModelSerializer):
@staticmethod @staticmethod
def get_is_success(obj): def get_is_success(obj):
if obj.status == 'success': if obj.status == ChangeSecretRecordStatusChoice.success.value:
return _("Success") return _("Success")
return _("Failed") return _("Failed")

View File

@ -36,14 +36,14 @@ def execute_account_automation_task(pid, trigger, tp):
instance.execute(trigger) instance.execute(trigger)
def record_task_activity_callback(self, record_id, *args, **kwargs): def record_task_activity_callback(self, record_ids, *args, **kwargs):
from accounts.models import ChangeSecretRecord from accounts.models import ChangeSecretRecord
with tmp_to_root_org(): with tmp_to_root_org():
record = get_object_or_none(ChangeSecretRecord, id=record_id) records = ChangeSecretRecord.objects.filter(id__in=record_ids)
if not record: if not records:
return return
resource_ids = [record.id] resource_ids = [str(i.id) for i in records]
org_id = record.execution.org_id org_id = records[0].execution.org_id
return resource_ids, org_id return resource_ids, org_id
@ -51,22 +51,26 @@ def record_task_activity_callback(self, record_id, *args, **kwargs):
queue='ansible', verbose_name=_('Execute automation record'), queue='ansible', verbose_name=_('Execute automation record'),
activity_callback=record_task_activity_callback activity_callback=record_task_activity_callback
) )
def execute_automation_record_task(record_id, tp): def execute_automation_record_task(record_ids, tp):
from accounts.models import ChangeSecretRecord from accounts.models import ChangeSecretRecord
task_name = gettext_noop('Execute automation record')
with tmp_to_root_org(): with tmp_to_root_org():
instance = get_object_or_none(ChangeSecretRecord, pk=record_id) records = ChangeSecretRecord.objects.filter(id__in=record_ids)
if not instance:
logger.error("No automation record found: {}".format(record_id)) if not records:
logger.error('No automation record found: {}'.format(record_ids))
return return
task_name = gettext_noop('Execute automation record') record = records[0]
record_map = {f'{record.asset_id}-{record.account_id}': str(record.id) for record in records}
task_snapshot = { task_snapshot = {
'secret': instance.new_secret,
'secret_type': instance.execution.snapshot.get('secret_type'),
'accounts': [str(instance.account_id)],
'assets': [str(instance.asset_id)],
'params': {}, 'params': {},
'record_id': record_id, 'record_map': record_map,
'secret': record.new_secret,
'secret_type': record.execution.snapshot.get('secret_type'),
'assets': [str(instance.asset_id) for instance in records],
'accounts': [str(instance.account_id) for instance in records],
} }
with tmp_to_org(instance.execution.org_id): with tmp_to_org(record.execution.org_id):
quickstart_automation_by_snapshot(task_name, tp, task_snapshot) quickstart_automation_by_snapshot(task_name, tp, task_snapshot)

View File

@ -55,7 +55,7 @@ def clean_historical_accounts():
history_model = Account.history.model history_model = Account.history.model
history_id_mapper = defaultdict(list) history_id_mapper = defaultdict(list)
ids = history_model.objects.values('id').annotate(count=Count('id', distinct=True)) \ ids = history_model.objects.values('id').annotate(count=Count('id')) \
.filter(count__gte=limit).values_list('id', flat=True) .filter(count__gte=limit).values_list('id', flat=True)
if not ids: if not ids:

View File

@ -29,7 +29,8 @@ def template_sync_related_accounts(template_id, user_id=None):
name = template.name name = template.name
username = template.username username = template.username
secret_type = template.secret_type secret_type = template.secret_type
print(f'\033[32m>>> 开始同步模版名称、用户名、密钥类型到相关联的账号 ({datetime.now().strftime("%Y-%m-%d %H:%M:%S")})') print(
f'\033[32m>>> 开始同步模板名称、用户名、密钥类型到相关联的账号 ({datetime.now().strftime("%Y-%m-%d %H:%M:%S")})')
with tmp_to_org(org_id): with tmp_to_org(org_id):
for account in accounts: for account in accounts:
account.name = name account.name = name

View File

@ -1,10 +1,10 @@
{% load i18n %} {% load i18n %}
<h3>{% trans 'Gather account change information' %}</h3>
<table style="width: 100%; border-collapse: collapse; max-width: 100%; text-align: left; margin-top: 20px;"> <table style="width: 100%; border-collapse: collapse; max-width: 100%; text-align: left; margin-top: 20px;">
<caption></caption> <caption></caption>
<tr style="background-color: #f2f2f2;"> <tr style="background-color: #f2f2f2;">
<th style="border: 1px solid #ddd; padding: 10px; font-weight: bold;">{% trans 'Asset' %}</th> <th style="border: 1px solid #ddd; padding: 10px;">{% trans 'Asset' %}</th>
<th style="border: 1px solid #ddd; padding: 10px;">{% trans 'Added account' %}</th> <th style="border: 1px solid #ddd; padding: 10px;">{% trans 'Added account' %}</th>
<th style="border: 1px solid #ddd; padding: 10px;">{% trans 'Deleted account' %}</th> <th style="border: 1px solid #ddd; padding: 10px;">{% trans 'Deleted account' %}</th>
</tr> </tr>

View File

@ -0,0 +1,36 @@
{% load i18n %}
<h3>{% trans 'Task name' %}: {{ name }}</h3>
<h3>{% trans 'Task execution id' %}: {{ execution_id }}</h3>
<p>{% trans 'Respectful' %} {{ recipient }}</p>
<p>{% trans 'Hello! The following is the failure of changing the password of your assets or pushing the account. Please check and handle it in time.' %}</p>
<table style="width: 100%; border-collapse: collapse; max-width: 100%; text-align: left; margin-top: 20px;">
<caption></caption>
<thead>
<tr style="background-color: #f2f2f2;">
<th style="border: 1px solid #ddd; padding: 10px;">{% trans 'Asset' %}</th>
<th style="border: 1px solid #ddd; padding: 10px;">{% trans 'Account' %}</th>
<th style="border: 1px solid #ddd; padding: 10px;">{% trans 'Error' %}</th>
</tr>
</thead>
<tbody>
{% for asset_name, account_username, error in asset_account_errors %}
<tr>
<td style="border: 1px solid #ddd; padding: 10px;">{{ asset_name }}</td>
<td style="border: 1px solid #ddd; padding: 10px;">{{ account_username }}</td>
<td style="border: 1px solid #ddd; padding: 10px;">
<div style="
max-width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;"
title="{{ error }}"
>
{{ error }}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@ -32,6 +32,7 @@ __all__ = [
class AssetFilterSet(BaseFilterSet): class AssetFilterSet(BaseFilterSet):
platform = django_filters.CharFilter(method='filter_platform') platform = django_filters.CharFilter(method='filter_platform')
exclude_platform = django_filters.CharFilter(field_name="platform__name", lookup_expr='exact', exclude=True)
domain = django_filters.CharFilter(method='filter_domain') domain = django_filters.CharFilter(method='filter_domain')
type = django_filters.CharFilter(field_name="platform__type", lookup_expr="exact") type = django_filters.CharFilter(field_name="platform__type", lookup_expr="exact")
category = django_filters.CharFilter(field_name="platform__category", lookup_expr="exact") category = django_filters.CharFilter(field_name="platform__category", lookup_expr="exact")
@ -92,7 +93,6 @@ class AssetViewSet(SuggestionMixin, OrgBulkModelViewSet):
model = Asset model = Asset
filterset_class = AssetFilterSet filterset_class = AssetFilterSet
search_fields = ("name", "address", "comment") search_fields = ("name", "address", "comment")
ordering = ('name',)
ordering_fields = ('name', 'address', 'connectivity', 'platform', 'date_updated', 'date_created') ordering_fields = ('name', 'address', 'connectivity', 'platform', 'date_updated', 'date_created')
serializer_classes = ( serializer_classes = (
("default", serializers.AssetSerializer), ("default", serializers.AssetSerializer),

View File

@ -19,7 +19,6 @@ class DomainViewSet(OrgBulkModelViewSet):
model = Domain model = Domain
filterset_fields = ("name",) filterset_fields = ("name",)
search_fields = filterset_fields search_fields = filterset_fields
ordering = ('name',)
serializer_classes = { serializer_classes = {
'default': serializers.DomainSerializer, 'default': serializers.DomainSerializer,
'list': serializers.DomainListSerializer, 'list': serializers.DomainListSerializer,
@ -30,6 +29,10 @@ class DomainViewSet(OrgBulkModelViewSet):
return serializers.DomainWithGatewaySerializer return serializers.DomainWithGatewaySerializer
return super().get_serializer_class() return super().get_serializer_class()
def partial_update(self, request, *args, **kwargs):
kwargs['partial'] = True
return self.update(request, *args, **kwargs)
class GatewayViewSet(HostViewSet): class GatewayViewSet(HostViewSet):
perm_model = Gateway perm_model = Gateway

View File

@ -21,6 +21,7 @@ class AssetPlatformViewSet(JMSModelViewSet):
} }
filterset_fields = ['name', 'category', 'type'] filterset_fields = ['name', 'category', 'type']
search_fields = ['name'] search_fields = ['name']
ordering = ['-internal', 'name']
rbac_perms = { rbac_perms = {
'categories': 'assets.view_platform', 'categories': 'assets.view_platform',
'type_constraints': 'assets.view_platform', 'type_constraints': 'assets.view_platform',

View File

@ -12,7 +12,7 @@ from sshtunnel import SSHTunnelForwarder
from assets.automations.methods import platform_automation_methods from assets.automations.methods import platform_automation_methods
from common.utils import get_logger, lazyproperty, is_openssh_format_key, ssh_pubkey_gen from common.utils import get_logger, lazyproperty, is_openssh_format_key, ssh_pubkey_gen
from ops.ansible import JMSInventory, PlaybookRunner, DefaultCallback from ops.ansible import JMSInventory, SuperPlaybookRunner, DefaultCallback
logger = get_logger(__name__) logger = get_logger(__name__)
@ -54,7 +54,7 @@ class SSHTunnelManager:
not_valid.append(k) not_valid.append(k)
else: else:
local_bind_port = server.local_bind_port local_bind_port = server.local_bind_port
host['ansible_host'] = jms_asset['address'] = host['login_host'] = '127.0.0.1' host['ansible_host'] = jms_asset['address'] = host['login_host'] = 'jms_celery'
host['ansible_port'] = jms_asset['port'] = host['login_port'] = local_bind_port host['ansible_port'] = jms_asset['port'] = host['login_port'] = local_bind_port
servers.append(server) servers.append(server)
@ -269,7 +269,7 @@ class BasePlaybookManager:
if not playbook_path: if not playbook_path:
continue continue
runer = PlaybookRunner( runer = SuperPlaybookRunner(
inventory_path, inventory_path,
playbook_path, playbook_path,
self.runtime_dir, self.runtime_dir,
@ -314,7 +314,7 @@ class BasePlaybookManager:
def delete_runtime_dir(self): def delete_runtime_dir(self):
if settings.DEBUG_DEV: if settings.DEBUG_DEV:
return return
shutil.rmtree(self.runtime_dir) shutil.rmtree(self.runtime_dir, ignore_errors=True)
def run(self, *args, **kwargs): def run(self, *args, **kwargs):
print(">>> 任务准备阶段\n") print(">>> 任务准备阶段\n")
@ -333,6 +333,7 @@ class BasePlaybookManager:
ssh_tunnel = SSHTunnelManager() ssh_tunnel = SSHTunnelManager()
ssh_tunnel.local_gateway_prepare(runner) ssh_tunnel.local_gateway_prepare(runner)
try: try:
kwargs.update({"clean_workspace": False})
cb = runner.run(**kwargs) cb = runner.run(**kwargs)
self.on_runner_success(runner, cb) self.on_runner_success(runner, cb)
except Exception as e: except Exception as e:

View File

@ -3,6 +3,7 @@
vars: vars:
ansible_shell_type: sh ansible_shell_type: sh
ansible_connection: local ansible_connection: local
ansible_python_interpreter: /opt/py3/bin/python
tasks: tasks:
- name: Test asset connection (pyfreerdp) - name: Test asset connection (pyfreerdp)

View File

@ -19,3 +19,6 @@
become_user: "{{ custom_become_user | default('') }}" become_user: "{{ custom_become_user | default('') }}"
become_password: "{{ custom_become_password | default('') }}" become_password: "{{ custom_become_password | default('') }}"
become_private_key_path: "{{ custom_become_private_key_path | default(None) }}" become_private_key_path: "{{ custom_become_private_key_path | default(None) }}"
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}"

View File

@ -0,0 +1,11 @@
- hosts: custom
gather_facts: no
vars:
ansible_connection: local
ansible_shell_type: sh
tasks:
- name: Test asset connection (telnet)
telnet_ping:
login_host: "{{ jms_asset.address }}"
login_port: "{{ jms_asset.port }}"

View File

@ -0,0 +1,16 @@
id: ping_by_telnet
name: "{{ 'Ping by telnet' | trans }}"
category:
- device
- host
type:
- all
method: ping
protocol: telnet
priority: 50
i18n:
Ping by telnet:
zh: '使用 Python 模块 telnet 测试主机可连接性'
en: 'Ping by telnet module'
ja: 'Pythonモジュールtelnetを使用したホスト接続性のテスト'

View File

@ -25,14 +25,22 @@ class PingManager(BasePlaybookManager):
def on_host_success(self, host, result): def on_host_success(self, host, result):
asset, account = self.host_asset_and_account_mapper.get(host) asset, account = self.host_asset_and_account_mapper.get(host)
asset.set_connectivity(Connectivity.OK) try:
if not account: asset.set_connectivity(Connectivity.OK)
return if not account:
account.set_connectivity(Connectivity.OK) return
account.set_connectivity(Connectivity.OK)
except Exception as e:
print(f'\033[31m Update account {account.name} or '
f'update asset {asset.name} connectivity failed: {e} \033[0m\n')
def on_host_error(self, host, error, result): def on_host_error(self, host, error, result):
asset, account = self.host_asset_and_account_mapper.get(host) asset, account = self.host_asset_and_account_mapper.get(host)
asset.set_connectivity(Connectivity.ERR) try:
if not account: asset.set_connectivity(Connectivity.ERR)
return if not account:
account.set_connectivity(Connectivity.ERR) return
account.set_connectivity(Connectivity.ERR)
except Exception as e:
print(f'\033[31m Update account {account.name} or '
f'update asset {asset.name} connectivity failed: {e} \033[0m\n')

View File

@ -92,18 +92,26 @@ class PingGatewayManager:
@staticmethod @staticmethod
def on_host_success(gateway, account): def on_host_success(gateway, account):
print('\033[32m {} -> {}\033[0m\n'.format(gateway, account)) print('\033[32m {} -> {}\033[0m\n'.format(gateway, account))
gateway.set_connectivity(Connectivity.OK) try:
if not account: gateway.set_connectivity(Connectivity.OK)
return if not account:
account.set_connectivity(Connectivity.OK) return
account.set_connectivity(Connectivity.OK)
except Exception as e:
print(f'\033[31m Update account {account.name} or '
f'update asset {gateway.name} connectivity failed: {e} \033[0m\n')
@staticmethod @staticmethod
def on_host_error(gateway, account, error): def on_host_error(gateway, account, error):
print('\033[31m {} -> {} 原因: {} \033[0m\n'.format(gateway, account, error)) print('\033[31m {} -> {} 原因: {} \033[0m\n'.format(gateway, account, error))
gateway.set_connectivity(Connectivity.ERR) try:
if not account: gateway.set_connectivity(Connectivity.ERR)
return if not account:
account.set_connectivity(Connectivity.ERR) return
account.set_connectivity(Connectivity.ERR)
except Exception as e:
print(f'\033[31m Update account {account.name} or '
f'update asset {gateway.name} connectivity failed: {e} \033[0m\n')
@staticmethod @staticmethod
def before_runner_start(): def before_runner_start():

View File

@ -38,6 +38,14 @@ class Protocol(ChoicesMixin, models.TextChoices):
cls.ssh: { cls.ssh: {
'port': 22, 'port': 22,
'secret_types': ['password', 'ssh_key'], 'secret_types': ['password', 'ssh_key'],
'setting': {
'old_ssh_version': {
'type': 'bool',
'default': False,
'label': _('Old SSH version'),
'help_text': _('Old SSH version like openssh 5.x or 6.x')
}
}
}, },
cls.sftp: { cls.sftp: {
'port': 22, 'port': 22,
@ -187,6 +195,14 @@ class Protocol(ChoicesMixin, models.TextChoices):
'port': 27017, 'port': 27017,
'required': True, 'required': True,
'secret_types': ['password'], 'secret_types': ['password'],
'setting': {
'auth_source': {
'type': 'str',
'default': 'admin',
'label': _('Auth source'),
'help_text': _('The database to authenticate against')
}
}
}, },
cls.redis: { cls.redis: {
'port': 6379, 'port': 6379,

View File

@ -73,3 +73,7 @@ class Gateway(Host):
def private_key_path(self): def private_key_path(self):
account = self.select_account account = self.select_account
return account.private_key_path if account else None return account.private_key_path if account else None
def get_private_key_path(self, path):
account = self.select_account
return account.get_private_key_path(path) if account else None

View File

@ -73,6 +73,10 @@ class FamilyMixin:
@classmethod @classmethod
def get_nodes_all_children(cls, nodes, with_self=True): def get_nodes_all_children(cls, nodes, with_self=True):
pattern = cls.get_nodes_children_key_pattern(nodes, with_self=with_self) pattern = cls.get_nodes_children_key_pattern(nodes, with_self=with_self)
if not pattern:
# 如果 pattern = ''
# key__iregex 报错 (1139, "Got error 'empty (sub)expression' from regexp")
return cls.objects.none()
return Node.objects.filter(key__iregex=pattern) return Node.objects.filter(key__iregex=pattern)
@classmethod @classmethod

View File

@ -1,12 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from django.db.models import Count from django.db.models import Count, 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
from common.serializers import ResourceLabelsMixin from common.serializers import ResourceLabelsMixin
from common.serializers.fields import ObjectRelatedField from common.serializers.fields import ObjectRelatedField
from orgs.mixins.serializers import BulkOrgResourceModelSerializer from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from assets.models.gateway import Gateway
from .gateway import GatewayWithAccountSecretSerializer from .gateway import GatewayWithAccountSecretSerializer
from ..models import Domain from ..models import Domain
@ -15,7 +16,7 @@ __all__ = ['DomainSerializer', 'DomainWithGatewaySerializer', 'DomainListSeriali
class DomainSerializer(ResourceLabelsMixin, BulkOrgResourceModelSerializer): class DomainSerializer(ResourceLabelsMixin, BulkOrgResourceModelSerializer):
gateways = ObjectRelatedField( gateways = ObjectRelatedField(
many=True, required=False, label=_('Gateway'), read_only=True, many=True, required=False, label=_('Gateway'), queryset=Gateway.objects
) )
class Meta: class Meta:
@ -25,6 +26,9 @@ class DomainSerializer(ResourceLabelsMixin, BulkOrgResourceModelSerializer):
fields_m2m = ['assets', 'gateways'] fields_m2m = ['assets', 'gateways']
read_only_fields = ['date_created'] read_only_fields = ['date_created']
fields = fields_small + fields_m2m + read_only_fields fields = fields_small + fields_m2m + read_only_fields
extra_kwargs = {
'assets': {'required': False},
}
def to_representation(self, instance): def to_representation(self, instance):
data = super().to_representation(instance) data = super().to_representation(instance)
@ -35,12 +39,17 @@ class DomainSerializer(ResourceLabelsMixin, BulkOrgResourceModelSerializer):
data['assets'] = [i for i in assets if str(i['id']) not in gateway_ids] data['assets'] = [i for i in assets if str(i['id']) not in gateway_ids]
return data return data
def update(self, instance, validated_data): def create(self, validated_data):
assets = validated_data.pop('assets', []) assets = validated_data.pop('assets', [])
assets = assets + list(instance.gateways) gateways = validated_data.pop('gateways', [])
validated_data['assets'] = assets validated_data['assets'] = assets + gateways
instance = super().update(instance, validated_data) return super().create(validated_data)
return instance
def update(self, instance, validated_data):
assets = validated_data.pop('assets', list(instance.assets.all()))
gateways = validated_data.pop('gateways', list(instance.gateways.all()))
validated_data['assets'] = assets + gateways
return super().update(instance, validated_data)
@classmethod @classmethod
def setup_eager_loading(cls, queryset): def setup_eager_loading(cls, queryset):
@ -58,7 +67,7 @@ class DomainListSerializer(DomainSerializer):
@classmethod @classmethod
def setup_eager_loading(cls, queryset): def setup_eager_loading(cls, queryset):
queryset = queryset.annotate( queryset = queryset.annotate(
assets_amount=Count('assets', distinct=True), assets_amount=Count('assets', filter=~Q(assets__platform__name='Gateway'), distinct=True),
) )
return queryset return queryset

View File

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from importlib import import_module from importlib import import_module
from django.conf import settings from django.conf import settings
@ -66,7 +65,7 @@ class FTPLogViewSet(OrgModelViewSet):
date_range_filter_fields = [ date_range_filter_fields = [
('date_start', ('date_from', 'date_to')) ('date_start', ('date_from', 'date_to'))
] ]
filterset_fields = ['user', 'asset', 'account', 'filename'] filterset_fields = ['user', 'asset', 'account', 'filename', 'session']
search_fields = filterset_fields search_fields = filterset_fields
ordering = ['-date_start'] ordering = ['-date_start']
http_method_names = ['post', 'get', 'head', 'options', 'patch'] http_method_names = ['post', 'get', 'head', 'options', 'patch']
@ -269,7 +268,7 @@ class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet):
return user_ids return user_ids
def get_queryset(self): def get_queryset(self):
keys = UserSession.get_keys() keys = user_session_manager.get_keys()
queryset = UserSession.objects.filter(key__in=keys) queryset = UserSession.objects.filter(key__in=keys)
if current_org.is_root(): if current_org.is_root():
return queryset return queryset
@ -288,6 +287,6 @@ class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet):
keys = queryset.values_list('key', flat=True) keys = queryset.values_list('key', flat=True)
for key in keys: for key in keys:
user_session_manager.decrement_or_remove(key) user_session_manager.remove(key)
queryset.delete() queryset.delete()
return Response(status=status.HTTP_200_OK) return Response(status=status.HTTP_200_OK)

View File

@ -12,7 +12,10 @@ from common.utils.timezone import as_current_tz
from jumpserver.utils import current_request from jumpserver.utils import current_request
from orgs.models import Organization from orgs.models import Organization
from orgs.utils import get_current_org_id from orgs.utils import get_current_org_id
from settings.models import Setting
from settings.serializers import SettingsSerializer from settings.serializers import SettingsSerializer
from users.models import Preference
from users.serializers import PreferenceSerializer
from .backends import get_operate_log_storage from .backends import get_operate_log_storage
logger = get_logger(__name__) logger = get_logger(__name__)
@ -87,19 +90,15 @@ class OperatorLogHandler(metaclass=Singleton):
return log_id, before, after return log_id, before, after
@staticmethod @staticmethod
def get_resource_display_from_setting(resource): def get_resource_display(resource):
resource_display = None if isinstance(resource, Setting):
setting_serializer = SettingsSerializer() serializer = SettingsSerializer()
label = setting_serializer.get_field_label(resource) resource_display = serializer.get_field_label(resource.name)
if label is not None: elif isinstance(resource, Preference):
resource_display = label serializer = PreferenceSerializer()
return resource_display resource_display = serializer.get_field_label(resource.name)
else:
def get_resource_display(self, resource): resource_display = str(resource)
resource_display = str(resource)
return_value = self.get_resource_display_from_setting(resource_display)
if return_value is not None:
resource_display = return_value
return resource_display return resource_display
@staticmethod @staticmethod

View File

@ -288,16 +288,9 @@ class UserSession(models.Model):
ttl = caches[settings.SESSION_CACHE_ALIAS].ttl(cache_key) ttl = caches[settings.SESSION_CACHE_ALIAS].ttl(cache_key)
return timezone.now() + timedelta(seconds=ttl) return timezone.now() + timedelta(seconds=ttl)
@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].iter_keys('*')
return [k.replace(cache_key_prefix, '') for k in keys]
@classmethod @classmethod
def clear_expired_sessions(cls): def clear_expired_sessions(cls):
keys = cls.get_keys() keys = user_session_manager.get_keys()
cls.objects.exclude(key__in=keys).delete() cls.objects.exclude(key__in=keys).delete()
class Meta: class Meta:

View File

@ -43,7 +43,7 @@ class FTPLogSerializer(serializers.ModelSerializer):
fields_small = fields_mini + [ fields_small = fields_mini + [
"user", "remote_addr", "asset", "account", "user", "remote_addr", "asset", "account",
"org_id", "operate", "filename", "date_start", "org_id", "operate", "filename", "date_start",
"is_success", "has_file", "is_success", "has_file", "session"
] ]
fields = fields_small fields = fields_small

View File

@ -36,6 +36,7 @@ class AuthBackendLabelMapping(LazyObject):
backend_label_mapping[settings.AUTH_BACKEND_AUTH_TOKEN] = _("Auth Token") backend_label_mapping[settings.AUTH_BACKEND_AUTH_TOKEN] = _("Auth Token")
backend_label_mapping[settings.AUTH_BACKEND_WECOM] = _("WeCom") backend_label_mapping[settings.AUTH_BACKEND_WECOM] = _("WeCom")
backend_label_mapping[settings.AUTH_BACKEND_FEISHU] = _("FeiShu") backend_label_mapping[settings.AUTH_BACKEND_FEISHU] = _("FeiShu")
backend_label_mapping[settings.AUTH_BACKEND_LARK] = 'Lark'
backend_label_mapping[settings.AUTH_BACKEND_SLACK] = _("Slack") backend_label_mapping[settings.AUTH_BACKEND_SLACK] = _("Slack")
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")

View File

@ -178,7 +178,7 @@ def on_django_start_set_operate_log_monitor_models(sender, **kwargs):
'PermedAsset', 'PermedAccount', 'MenuPermission', 'PermedAsset', 'PermedAccount', 'MenuPermission',
'Permission', 'TicketSession', 'ApplyLoginTicket', 'Permission', 'TicketSession', 'ApplyLoginTicket',
'ApplyCommandTicket', 'ApplyLoginAssetTicket', 'ApplyCommandTicket', 'ApplyLoginAssetTicket',
'FavoriteAsset', 'Asset' 'FavoriteAsset',
} }
for i, app in enumerate(apps.get_models(), 1): for i, app in enumerate(apps.get_models(), 1):
app_name = app._meta.app_label app_name = app._meta.app_label

View File

@ -7,18 +7,17 @@ import subprocess
from celery import shared_task from celery import shared_task
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.db import transaction
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from common.const.crontab import CRONTAB_AT_AM_TWO from common.const.crontab import CRONTAB_AT_AM_TWO
from common.utils import get_log_keep_day, get_logger
from common.storage.ftp_file import FTPFileStorageHandler from common.storage.ftp_file import FTPFileStorageHandler
from ops.celery.decorator import ( from common.utils import get_log_keep_day, get_logger
register_as_period_task, after_app_shutdown_clean_periodic from ops.celery.decorator import register_as_period_task
)
from ops.models import CeleryTaskExecution from ops.models import CeleryTaskExecution
from terminal.models import Session, Command
from terminal.backends import server_replay_storage from terminal.backends import server_replay_storage
from terminal.models import Session, Command
from .models import UserLoginLog, OperateLog, FTPLog, ActivityLog, PasswordChangeLog from .models import UserLoginLog, OperateLog, FTPLog, ActivityLog, PasswordChangeLog
logger = get_logger(__name__) logger = get_logger(__name__)
@ -57,9 +56,9 @@ def clean_ftp_log_period():
now = timezone.now() now = timezone.now()
days = get_log_keep_day('FTP_LOG_KEEP_DAYS') days = get_log_keep_day('FTP_LOG_KEEP_DAYS')
expired_day = now - datetime.timedelta(days=days) expired_day = now - datetime.timedelta(days=days)
file_store_dir = os.path.join(default_storage.base_location, 'ftp_file') file_store_dir = os.path.join(default_storage.base_location, FTPLog.upload_to)
FTPLog.objects.filter(date_start__lt=expired_day).delete() FTPLog.objects.filter(date_start__lt=expired_day).delete()
command = "find %s -mtime +%s -exec rm -f {} \\;" % ( command = "find %s -mtime +%s -type f -exec rm -f {} \\;" % (
file_store_dir, days file_store_dir, days
) )
subprocess.call(command, shell=True) subprocess.call(command, shell=True)
@ -84,6 +83,15 @@ def clean_celery_tasks_period():
subprocess.call(command, shell=True) subprocess.call(command, shell=True)
def batch_delete(queryset, batch_size=3000):
model = queryset.model
count = queryset.count()
with transaction.atomic():
for i in range(0, count, batch_size):
pks = queryset[i:i + batch_size].values_list('id', flat=True)
model.objects.filter(id__in=list(pks)).delete()
def clean_expired_session_period(): def clean_expired_session_period():
logger.info("Start clean expired session record, commands and replay") logger.info("Start clean expired session record, commands and replay")
days = get_log_keep_day('TERMINAL_SESSION_KEEP_DURATION') days = get_log_keep_day('TERMINAL_SESSION_KEEP_DURATION')
@ -93,9 +101,9 @@ def clean_expired_session_period():
expired_commands = Command.objects.filter(timestamp__lt=timestamp) expired_commands = Command.objects.filter(timestamp__lt=timestamp)
replay_dir = os.path.join(default_storage.base_location, 'replay') replay_dir = os.path.join(default_storage.base_location, 'replay')
expired_sessions.delete() batch_delete(expired_sessions)
logger.info("Clean session item done") logger.info("Clean session item done")
expired_commands.delete() batch_delete(expired_commands)
logger.info("Clean session command done") logger.info("Clean session command done")
command = "find %s -mtime +%s \\( -name '*.json' -o -name '*.tar' -o -name '*.gz' \\) -exec rm -f {} \\;" % ( command = "find %s -mtime +%s \\( -name '*.json' -o -name '*.tar' -o -name '*.gz' \\) -exec rm -f {} \\;" % (
replay_dir, days replay_dir, days
@ -108,7 +116,6 @@ def clean_expired_session_period():
@shared_task(verbose_name=_('Clean audits session task log')) @shared_task(verbose_name=_('Clean audits session task log'))
@register_as_period_task(crontab=CRONTAB_AT_AM_TWO) @register_as_period_task(crontab=CRONTAB_AT_AM_TWO)
@after_app_shutdown_clean_periodic
def clean_audits_log_period(): def clean_audits_log_period():
print("Start clean audit session task log") print("Start clean audit session task log")
clean_login_log_period() clean_login_log_period()

View File

@ -2,13 +2,15 @@
# #
from .access_key import * from .access_key import *
from .common import *
from .confirm import * from .confirm import *
from .connection_token import * from .connection_token import *
from .feishu import * from .feishu import *
from .lark import *
from .login_confirm import * from .login_confirm import *
from .mfa import * from .mfa import *
from .password import * from .password import *
from .session import *
from .sso import * from .sso import *
from .temp_token import * from .temp_token import *
from .token import * from .token import *
from .common import *

View File

@ -12,7 +12,6 @@ from common.permissions import IsValidUser, OnlySuperUser
from common.utils import get_logger from common.utils import get_logger
from users.models import User from users.models import User
logger = get_logger(__file__) logger = get_logger(__file__)
@ -24,6 +23,7 @@ class QRUnBindBase(APIView):
'wecom': {'user_field': 'wecom_id', 'not_bind_err': errors.WeComNotBound}, 'wecom': {'user_field': 'wecom_id', 'not_bind_err': errors.WeComNotBound},
'dingtalk': {'user_field': 'dingtalk_id', 'not_bind_err': errors.DingTalkNotBound}, 'dingtalk': {'user_field': 'dingtalk_id', 'not_bind_err': errors.DingTalkNotBound},
'feishu': {'user_field': 'feishu_id', 'not_bind_err': errors.FeiShuNotBound}, 'feishu': {'user_field': 'feishu_id', 'not_bind_err': errors.FeiShuNotBound},
'lark': {'user_field': 'lark_id', 'not_bind_err': errors.LarkNotBound},
'slack': {'user_field': 'slack_id', 'not_bind_err': errors.SlackNotBound}, 'slack': {'user_field': 'slack_id', 'not_bind_err': errors.SlackNotBound},
} }
user = self.user user = self.user

View File

@ -223,12 +223,17 @@ class ExtraActionApiMixin(RDPFileClientProtocolURLMixin):
validate_exchange_token: callable validate_exchange_token: callable
@action(methods=['POST', 'GET'], detail=True, url_path='rdp-file') @action(methods=['POST', 'GET'], detail=True, url_path='rdp-file')
def get_rdp_file(self, *args, **kwargs): def get_rdp_file(self, request, *args, **kwargs):
token = self.get_object() token = self.get_object()
token.is_valid() token.is_valid()
filename, content = self.get_rdp_file_info(token) filename, content = self.get_rdp_file_info(token)
filename = '{}.rdp'.format(filename)
response = HttpResponse(content, content_type='application/octet-stream') response = HttpResponse(content, content_type='application/octet-stream')
if is_true(request.query_params.get('reusable')):
token.set_reusable(True)
filename = '{}-{}'.format(filename, token.date_expired.strftime('%Y%m%d_%H%M%S'))
filename += '.rdp'
response['Content-Disposition'] = 'attachment; filename*=UTF-8\'\'%s' % filename response['Content-Disposition'] = 'attachment; filename*=UTF-8\'\'%s' % filename
return response return response
@ -379,6 +384,7 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
if account.username != AliasAccount.INPUT: if account.username != AliasAccount.INPUT:
data['input_username'] = '' data['input_username'] = ''
ticket = self._validate_acl(user, asset, account) ticket = self._validate_acl(user, asset, account)
if ticket: if ticket:
data['from_ticket'] = ticket data['from_ticket'] = ticket
@ -413,7 +419,10 @@ 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=user, asset=asset, account=account) kwargs = {'user': user, 'asset': asset, 'account': account}
if account.username == AliasAccount.INPUT:
kwargs['account_username'] = self.input_username
acls = LoginAssetACL.filter_queryset(**kwargs)
ip = get_request_ip_or_data(self.request) ip = get_request_ip_or_data(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:
@ -503,20 +512,16 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet):
token.is_valid() token.is_valid()
serializer = self.get_serializer(instance=token) serializer = self.get_serializer(instance=token)
expire_now = request.data.get('expire_now', None) expire_now = request.data.get('expire_now', True)
asset_type = token.asset.type asset_type = token.asset.type
# 设置默认值 # 设置默认值
if expire_now is None: if asset_type in ['k8s', 'kubernetes']:
# TODO 暂时特殊处理 k8s 不过期 expire_now = False
if asset_type in ['k8s', 'kubernetes']:
expire_now = False
else:
expire_now = not settings.CONNECTION_TOKEN_REUSABLE
if is_false(expire_now): if token.is_reusable and settings.CONNECTION_TOKEN_REUSABLE:
logger.debug('Api specified, now expire now')
elif token.is_reusable and settings.CONNECTION_TOKEN_REUSABLE:
logger.debug('Token is reusable, not expire now') logger.debug('Token is reusable, not expire now')
elif is_false(expire_now):
logger.debug('Api specified, now expire now')
else: else:
token.expire() token.expire()

View File

@ -0,0 +1,8 @@
from common.utils import get_logger
from .feishu import FeiShuEventSubscriptionCallback
logger = get_logger(__name__)
class LarkEventSubscriptionCallback(FeiShuEventSubscriptionCallback):
pass

View File

@ -9,6 +9,7 @@ from common.utils import get_logger
from .. import errors, mixins from .. import errors, mixins
__all__ = ['TicketStatusApi'] __all__ = ['TicketStatusApi']
logger = get_logger(__name__) logger = get_logger(__name__)

View File

@ -0,0 +1,68 @@
import time
from threading import Thread
from django.conf import settings
from django.contrib.auth import logout
from django.contrib.auth.models import AnonymousUser
from rest_framework import generics
from rest_framework import status
from rest_framework.response import Response
from common.sessions.cache import user_session_manager
from common.utils import get_logger
__all__ = ['UserSessionApi']
logger = get_logger(__name__)
class UserSessionManager:
def __init__(self, request):
self.request = request
self.session = request.session
def connect(self):
user_session_manager.add_or_increment(self.session.session_key)
def disconnect(self):
user_session_manager.decrement(self.session.session_key)
if self.should_delete_session():
thread = Thread(target=self.delay_delete_session)
thread.start()
def should_delete_session(self):
return (self.session.modified or settings.SESSION_SAVE_EVERY_REQUEST) and \
not self.session.is_empty() and \
self.session.get_expire_at_browser_close() and \
not user_session_manager.check_active(self.session.session_key)
def delay_delete_session(self):
timeout = 6
check_interval = 0.5
start_time = time.time()
while time.time() - start_time < timeout:
time.sleep(check_interval)
if user_session_manager.check_active(self.session.session_key):
return
logout(self.request)
class UserSessionApi(generics.RetrieveDestroyAPIView):
permission_classes = ()
def retrieve(self, request, *args, **kwargs):
if isinstance(request.user, AnonymousUser):
return Response(status=status.HTTP_200_OK)
UserSessionManager(request).connect()
return Response(status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs):
if isinstance(request.user, AnonymousUser):
return Response(status=status.HTTP_200_OK)
UserSessionManager(request).disconnect()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -5,11 +5,13 @@ from django.conf import settings
from django.contrib.auth import login 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 import serializers
from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
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 authentication.errors import ACLError
from common.api import JMSGenericViewSet from common.api import JMSGenericViewSet
from common.const.http import POST, GET from common.const.http import POST, GET
from common.permissions import OnlySuperUser from common.permissions import OnlySuperUser
@ -17,7 +19,10 @@ from common.serializers import EmptySerializer
from common.utils import reverse, safe_next_url from common.utils import reverse, safe_next_url
from common.utils.timezone import utc_now from common.utils.timezone import utc_now
from users.models import User from users.models import User
from ..errors import SSOAuthClosed from users.utils import LoginBlockUtil, LoginIpBlockUtil
from ..errors import (
SSOAuthClosed, AuthFailedError, LoginConfirmBaseError, SSOAuthKeyTTLError
)
from ..filters import AuthKeyQueryDeclaration from ..filters import AuthKeyQueryDeclaration
from ..mixins import AuthMixin from ..mixins import AuthMixin
from ..models import SSOToken from ..models import SSOToken
@ -63,31 +68,58 @@ class SSOViewSet(AuthMixin, JMSGenericViewSet):
此接口违反了 `Restful` 的规范 此接口违反了 `Restful` 的规范
`GET` 应该是安全的方法但此接口是不安全的 `GET` 应该是安全的方法但此接口是不安全的
""" """
status_code = status.HTTP_400_BAD_REQUEST
request.META['HTTP_X_JMS_LOGIN_TYPE'] = 'W' request.META['HTTP_X_JMS_LOGIN_TYPE'] = 'W'
authkey = request.query_params.get(AUTH_KEY) authkey = request.query_params.get(AUTH_KEY)
next_url = request.query_params.get(NEXT_URL) next_url = request.query_params.get(NEXT_URL)
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:
if not authkey:
raise serializers.ValidationError("authkey is required")
authkey = UUID(authkey) authkey = UUID(authkey)
token = SSOToken.objects.get(authkey=authkey, expired=False) token = SSOToken.objects.get(authkey=authkey, expired=False)
# 先过期,只能访问这一次 except (ValueError, SSOToken.DoesNotExist, serializers.ValidationError) as e:
error_msg = str(e)
self.send_auth_signal(success=False, reason=error_msg)
return Response({'error': error_msg}, status=status_code)
error_msg = None
user = token.user
username = user.username
ip = self.get_request_ip()
try:
if (utc_now().timestamp() - token.date_created.timestamp()) > settings.AUTH_SSO_AUTHKEY_TTL:
raise SSOAuthKeyTTLError()
self._check_is_block(username, True)
self._check_only_allow_exists_user_auth(username)
self._check_login_acl(user, ip)
self.check_user_login_confirm_if_need(user)
self.request.session['auth_backend'] = settings.AUTH_BACKEND_SSO
login(self.request, user, settings.AUTH_BACKEND_SSO)
self.send_auth_signal(success=True, user=user)
self.mark_mfa_ok('otp', user)
LoginIpBlockUtil(ip).clean_block_if_need()
LoginBlockUtil(username, ip).clean_failed_count()
self.clear_auth_mark()
except (ACLError, LoginConfirmBaseError): # 无需记录日志
pass
except (AuthFailedError, SSOAuthKeyTTLError) as e:
error_msg = e.msg
except Exception as e:
error_msg = str(e)
finally:
token.expired = True token.expired = True
token.save() token.save()
except (ValueError, SSOToken.DoesNotExist):
self.send_auth_signal(success=False, reason='authkey_invalid')
return HttpResponseRedirect(next_url)
# 判断是否过期 if error_msg:
if (utc_now().timestamp() - token.date_created.timestamp()) > settings.AUTH_SSO_AUTHKEY_TTL: self.send_auth_signal(success=False, username=username, reason=error_msg)
self.send_auth_signal(success=False, reason='authkey_timeout') return Response({'error': error_msg}, status=status_code)
else:
return HttpResponseRedirect(next_url) return HttpResponseRedirect(next_url)
user = token.user
login(self.request, user, settings.AUTH_BACKEND_SSO)
self.send_auth_signal(success=True, user=user)
return HttpResponseRedirect(next_url)

View File

@ -4,10 +4,13 @@ from django.contrib import auth
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
from django.utils.http import urlencode from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _
from authentication.utils import build_absolute_uri from authentication.utils import build_absolute_uri
from common.utils import get_logger from authentication.views.mixins import FlashMessageMixin
from authentication.mixins import authenticate from authentication.mixins import authenticate
from common.utils import get_logger
logger = get_logger(__file__) logger = get_logger(__file__)
@ -39,7 +42,7 @@ class OAuth2AuthRequestView(View):
return HttpResponseRedirect(redirect_url) return HttpResponseRedirect(redirect_url)
class OAuth2AuthCallbackView(View): class OAuth2AuthCallbackView(View, FlashMessageMixin):
http_method_names = ['get', ] http_method_names = ['get', ]
def get(self, request): def get(self, request):
@ -51,6 +54,11 @@ class OAuth2AuthCallbackView(View):
if 'code' in callback_params: if 'code' in callback_params:
logger.debug(log_prompt.format('Process authenticate')) logger.debug(log_prompt.format('Process authenticate'))
user = authenticate(code=callback_params['code'], request=request) user = authenticate(code=callback_params['code'], request=request)
if err_msg := getattr(request, 'error_message', ''):
login_url = reverse('authentication:login') + '?admin=1'
return self.get_failed_response(login_url, title=_('Authentication failed'), msg=err_msg)
if user and user.is_valid: if user and user.is_valid:
logger.debug(log_prompt.format('Login: {}'.format(user))) logger.debug(log_prompt.format('Login: {}'.format(user)))
auth.login(self.request, user) auth.login(self.request, user)

View File

@ -55,6 +55,12 @@ class FeiShuAuthentication(JMSModelBackend):
pass pass
class LarkAuthentication(FeiShuAuthentication):
@staticmethod
def is_enabled():
return settings.AUTH_LARK
class SlackAuthentication(JMSModelBackend): class SlackAuthentication(JMSModelBackend):
""" """
什么也不做呀😺 什么也不做呀😺
@ -72,5 +78,6 @@ class AuthorizationTokenAuthentication(JMSModelBackend):
""" """
什么也不做呀😺 什么也不做呀😺
""" """
def authenticate(self, request, **kwargs): def authenticate(self, request, **kwargs):
pass pass

View File

@ -52,6 +52,10 @@ class AuthFailedError(Exception):
return str(self.msg) return str(self.msg)
class SSOAuthKeyTTLError(Exception):
msg = 'sso_authkey_timeout'
class BlockGlobalIpLoginError(AuthFailedError): class BlockGlobalIpLoginError(AuthFailedError):
error = 'block_global_ip_login' error = 'block_global_ip_login'

View File

@ -33,6 +33,11 @@ class FeiShuNotBound(JMSException):
default_detail = _('FeiShu is not bound') default_detail = _('FeiShu is not bound')
class LarkNotBound(JMSException):
default_code = 'lark_not_bound'
default_detail = _('Lark is not bound')
class SlackNotBound(JMSException): class SlackNotBound(JMSException):
default_code = 'slack_not_bound' default_code = 'slack_not_bound'
default_detail = _('Slack is not bound') default_detail = _('Slack is not bound')

View File

@ -17,10 +17,6 @@ class EncryptedField(forms.CharField):
class UserLoginForm(forms.Form): class UserLoginForm(forms.Form):
days_auto_login = int(settings.SESSION_COOKIE_AGE / 3600 / 24)
disable_days_auto_login = settings.SESSION_EXPIRE_AT_BROWSER_CLOSE \
or days_auto_login < 1
username = forms.CharField( username = forms.CharField(
label=_('Username'), max_length=100, label=_('Username'), max_length=100,
widget=forms.TextInput(attrs={ widget=forms.TextInput(attrs={
@ -34,15 +30,15 @@ class UserLoginForm(forms.Form):
) )
auto_login = forms.BooleanField( auto_login = forms.BooleanField(
required=False, initial=False, required=False, initial=False,
widget=forms.CheckboxInput( widget=forms.CheckboxInput()
attrs={'disabled': disable_days_auto_login}
)
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
auto_login_field = self.fields['auto_login'] auto_login_field = self.fields['auto_login']
auto_login_field.label = _("{} days auto login").format(self.days_auto_login or 1) auto_login_field.label = _("Auto login")
if settings.SESSION_EXPIRE_AT_BROWSER_CLOSE:
auto_login_field.widget = forms.HiddenInput()
def confirm_login_allowed(self, user): def confirm_login_allowed(self, user):
if not user.is_staff: if not user.is_staff:

View File

@ -363,7 +363,6 @@ class AuthACLMixin:
if acl.is_action(acl.ActionChoices.notice): if acl.is_action(acl.ActionChoices.notice):
self.request.session['auth_notice_required'] = '1' self.request.session['auth_notice_required'] = '1'
self.request.session['auth_acl_id'] = str(acl.id) self.request.session['auth_acl_id'] = str(acl.id)
return
def _check_third_party_login_acl(self): def _check_third_party_login_acl(self):
request = self.request request = self.request

View File

@ -82,12 +82,15 @@ class ConnectionToken(JMSOrgBaseModel):
self.save(update_fields=['date_expired']) self.save(update_fields=['date_expired'])
def set_reusable(self, is_reusable): def set_reusable(self, is_reusable):
if not settings.CONNECTION_TOKEN_REUSABLE:
return
self.is_reusable = is_reusable self.is_reusable = is_reusable
if self.is_reusable: if self.is_reusable:
seconds = settings.CONNECTION_TOKEN_REUSABLE_EXPIRATION seconds = settings.CONNECTION_TOKEN_REUSABLE_EXPIRATION
else: else:
seconds = settings.CONNECTION_TOKEN_ONETIME_EXPIRATION seconds = settings.CONNECTION_TOKEN_ONETIME_EXPIRATION
self.date_expired = timezone.now() + timedelta(seconds=seconds)
self.date_expired = self.date_created + timedelta(seconds=seconds)
self.save(update_fields=['is_reusable', 'date_expired']) self.save(update_fields=['is_reusable', 'date_expired'])
def renewal(self): def renewal(self):

View File

@ -1,5 +1,3 @@
from importlib import import_module
from django.conf import settings from django.conf import settings
from django.contrib.auth import user_logged_in from django.contrib.auth import user_logged_in
from django.core.cache import cache from django.core.cache import cache
@ -8,6 +6,7 @@ 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 audits.models import UserSession
from common.sessions.cache import user_session_manager
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
@ -32,8 +31,7 @@ def on_user_auth_login_success(sender, user, request, **kwargs):
lock_key = 'single_machine_login_' + str(user.id) lock_key = 'single_machine_login_' + str(user.id)
session_key = cache.get(lock_key) session_key = cache.get(lock_key)
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) user_session_manager.remove(session_key)
session.delete()
UserSession.objects.filter(key=session_key).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)

View File

@ -95,6 +95,7 @@ function doRequestAuth() {
} }
clearInterval(interval); clearInterval(interval);
clearInterval(checkInterval); clearInterval(checkInterval);
cancelTicket();
$(".copy-btn").attr('disabled', 'disabled'); $(".copy-btn").attr('disabled', 'disabled');
errorMsgRef.html(data.msg) errorMsgRef.html(data.msg)
} }

View File

@ -22,6 +22,9 @@ urlpatterns = [
path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(), path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(),
name='feishu-event-subscription-callback'), name='feishu-event-subscription-callback'),
path('lark/event/subscription/callback/', api.LarkEventSubscriptionCallback.as_view(),
name='lark-event-subscription-callback'),
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'), path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('confirm-oauth/', api.ConfirmBindORUNBindOAuth.as_view(), name='confirm-oauth'), path('confirm-oauth/', api.ConfirmBindORUNBindOAuth.as_view(), name='confirm-oauth'),
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'), path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
@ -32,6 +35,7 @@ urlpatterns = [
path('password/reset-code/', api.UserResetPasswordSendCodeApi.as_view(), name='reset-password-code'), path('password/reset-code/', api.UserResetPasswordSendCodeApi.as_view(), name='reset-password-code'),
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'), path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
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'),
path('user-session/', api.UserSessionApi.as_view(), name='user-session'),
] ]
urlpatterns += router.urls + passkey_urlpatterns urlpatterns += router.urls + passkey_urlpatterns

View File

@ -49,6 +49,12 @@ urlpatterns = [
path('feishu/qr/bind/callback/', views.FeiShuQRBindCallbackView.as_view(), name='feishu-qr-bind-callback'), path('feishu/qr/bind/callback/', views.FeiShuQRBindCallbackView.as_view(), name='feishu-qr-bind-callback'),
path('feishu/qr/login/callback/', views.FeiShuQRLoginCallbackView.as_view(), name='feishu-qr-login-callback'), path('feishu/qr/login/callback/', views.FeiShuQRLoginCallbackView.as_view(), name='feishu-qr-login-callback'),
path('lark/bind/start/', views.LarkEnableStartView.as_view(), name='lark-bind-start'),
path('lark/qr/bind/', views.LarkQRBindView.as_view(), name='lark-qr-bind'),
path('lark/qr/login/', views.LarkQRLoginView.as_view(), name='lark-qr-login'),
path('lark/qr/bind/callback/', views.LarkQRBindCallbackView.as_view(), name='lark-qr-bind-callback'),
path('lark/qr/login/callback/', views.LarkQRLoginCallbackView.as_view(), name='lark-qr-login-callback'),
path('slack/bind/start/', views.SlackEnableStartView.as_view(), name='slack-bind-start'), path('slack/bind/start/', views.SlackEnableStartView.as_view(), name='slack-bind-start'),
path('slack/qr/bind/', views.SlackQRBindView.as_view(), name='slack-qr-bind'), path('slack/qr/bind/', views.SlackQRBindView.as_view(), name='slack-qr-bind'),
path('slack/qr/login/', views.SlackQRLoginView.as_view(), name='slack-qr-login'), path('slack/qr/login/', views.SlackQRLoginView.as_view(), name='slack-qr-login'),

View File

@ -1,8 +1,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from .login import *
from .mfa import *
from .wecom import *
from .dingtalk import * from .dingtalk import *
from .feishu import * from .feishu import *
from .lark import *
from .login import *
from .mfa import *
from .slack import * from .slack import *
from .wecom import *

View File

@ -1,8 +1,8 @@
from functools import lru_cache from functools import lru_cache
from django.conf import settings from django.conf import settings
from django.db.utils import IntegrityError
from django.contrib.auth import logout as auth_logout from django.contrib.auth import logout as auth_logout
from django.db.utils import IntegrityError
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
@ -12,8 +12,8 @@ from authentication import errors
from authentication.mixins import AuthMixin from authentication.mixins import AuthMixin
from authentication.notifications import OAuthBindMessage from authentication.notifications import OAuthBindMessage
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.common import get_request_ip from common.utils.common import get_request_ip
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 users.signal_handlers import check_only_allow_exist_user_auth
from .mixins import FlashMessageMixin from .mixins import FlashMessageMixin
@ -83,7 +83,15 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, IMClientMixin, View):
if not self.verify_state(): if not self.verify_state():
return self.get_verify_state_failed_response(redirect_url) return self.get_verify_state_failed_response(redirect_url)
user_id, other_info = self.client.get_user_id_by_code(code) try:
user_id, other_info = self.client.get_user_id_by_code(code)
except Exception:
response = self.get_failed_response(
login_url, title=self.msg_client_err,
msg=self.msg_not_found_user_from_client_err
)
return response
if not user_id: if not user_id:
# 正常流程不会出这个错误hack 行为 # 正常流程不会出这个错误hack 行为
err = self.msg_not_found_user_from_client_err err = self.msg_not_found_user_from_client_err

View File

@ -21,24 +21,45 @@ from .mixins import FlashMessageMixin
logger = get_logger(__file__) logger = get_logger(__file__)
FEISHU_STATE_SESSION_KEY = '_feishu_state'
class FeiShuEnableStartView(UserVerifyPasswordView):
category = 'feishu'
def get_success_url(self):
referer = self.request.META.get('HTTP_REFERER')
redirect_url = self.request.GET.get("redirect_url")
success_url = reverse(f'authentication:{self.category}-qr-bind')
success_url += '?' + urlencode({
'redirect_url': redirect_url or referer
})
return success_url
class FeiShuQRMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashMessageMixin, View): class FeiShuQRMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashMessageMixin, View):
category = 'feishu'
error = _('FeiShu Error')
error_msg = _('FeiShu is already bound')
state_session_key = f'_{category}_state'
@property
def url_object(self):
return URL()
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
try: try:
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
except APIException as e: except APIException as e:
msg = str(e.detail) msg = str(e.detail)
return self.get_failed_response( return self.get_failed_response(
'/', '/', self.error, msg
_('FeiShu Error'),
msg
) )
def verify_state(self): def verify_state(self):
state = self.request.GET.get('state') state = self.request.GET.get('state')
session_state = self.request.session.get(FEISHU_STATE_SESSION_KEY) session_state = self.request.session.get(self.state_session_key)
if state != session_state: if state != session_state:
return False return False
return True return True
@ -49,19 +70,18 @@ class FeiShuQRMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashMe
def get_qr_url(self, redirect_uri): def get_qr_url(self, redirect_uri):
state = random_string(16) state = random_string(16)
self.request.session[FEISHU_STATE_SESSION_KEY] = state self.request.session[self.state_session_key] = state
params = { params = {
'app_id': settings.FEISHU_APP_ID, 'app_id': getattr(settings, f'{self.category}_APP_ID'.upper()),
'state': state, 'state': state,
'redirect_uri': redirect_uri, 'redirect_uri': redirect_uri,
} }
url = URL().authen + '?' + urlencode(params) url = self.url_object.authen + '?' + urlencode(params)
return url return url
def get_already_bound_response(self, redirect_url): def get_already_bound_response(self, redirect_url):
msg = _('FeiShu is already bound') response = self.get_failed_response(redirect_url, self.error_msg, self.error_msg)
response = self.get_failed_response(redirect_url, msg, msg)
return response return response
@ -71,7 +91,7 @@ class FeiShuQRBindView(FeiShuQRMixin, View):
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url') redirect_url = request.GET.get('redirect_url')
redirect_uri = reverse('authentication:feishu-qr-bind-callback', external=True) redirect_uri = reverse(f'authentication:{self.category}-qr-bind-callback', external=True)
redirect_uri += '?' + urlencode({'redirect_url': redirect_url}) redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
url = self.get_qr_url(redirect_uri) url = self.get_qr_url(redirect_uri)
@ -81,25 +101,16 @@ class FeiShuQRBindView(FeiShuQRMixin, View):
class FeiShuQRBindCallbackView(FeiShuQRMixin, BaseBindCallbackView): class FeiShuQRBindCallbackView(FeiShuQRMixin, BaseBindCallbackView):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
client_type_path = 'common.sdk.im.feishu.FeiShu'
client_auth_params = {'app_id': 'FEISHU_APP_ID', 'app_secret': 'FEISHU_APP_SECRET'}
auth_type = 'feishu' auth_type = 'feishu'
auth_type_label = _('FeiShu') auth_type_label = _('FeiShu')
client_type_path = f'common.sdk.im.{auth_type}.FeiShu'
@property
class FeiShuEnableStartView(UserVerifyPasswordView): def client_auth_params(self):
return {
def get_success_url(self): 'app_id': f'{self.auth_type}_APP_ID'.upper(),
referer = self.request.META.get('HTTP_REFERER') 'app_secret': f'{self.auth_type}_APP_SECRET'.upper()
redirect_url = self.request.GET.get("redirect_url") }
success_url = reverse('authentication:feishu-qr-bind')
success_url += '?' + urlencode({
'redirect_url': redirect_url or referer
})
return success_url
class FeiShuQRLoginView(FeiShuQRMixin, View): class FeiShuQRLoginView(FeiShuQRMixin, View):
@ -107,7 +118,7 @@ class FeiShuQRLoginView(FeiShuQRMixin, View):
def get(self, request: HttpRequest): def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url') or reverse('index') redirect_url = request.GET.get('redirect_url') or reverse('index')
redirect_uri = reverse('authentication:feishu-qr-login-callback', external=True) redirect_uri = reverse(f'authentication:{self.category}-qr-login-callback', external=True)
redirect_uri += '?' + urlencode({ redirect_uri += '?' + urlencode({
'redirect_url': redirect_url, 'redirect_url': redirect_url,
}) })
@ -119,11 +130,19 @@ class FeiShuQRLoginView(FeiShuQRMixin, View):
class FeiShuQRLoginCallbackView(FeiShuQRMixin, BaseLoginCallbackView): class FeiShuQRLoginCallbackView(FeiShuQRMixin, BaseLoginCallbackView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
client_type_path = 'common.sdk.im.feishu.FeiShu'
client_auth_params = {'app_id': 'FEISHU_APP_ID', 'app_secret': 'FEISHU_APP_SECRET'}
user_type = 'feishu' user_type = 'feishu'
auth_backend = 'AUTH_BACKEND_FEISHU' auth_type = user_type
client_type_path = f'common.sdk.im.{auth_type}.FeiShu'
msg_client_err = _('FeiShu Error') msg_client_err = _('FeiShu Error')
msg_user_not_bound_err = _('FeiShu is not bound') msg_user_not_bound_err = _('FeiShu is not bound')
msg_not_found_user_from_client_err = _('Failed to get user from FeiShu') msg_not_found_user_from_client_err = _('Failed to get user from FeiShu')
auth_backend = f'AUTH_BACKEND_{auth_type}'.upper()
@property
def client_auth_params(self):
return {
'app_id': f'{self.auth_type}_APP_ID'.upper(),
'app_secret': f'{self.auth_type}_APP_SECRET'.upper()
}

View File

@ -0,0 +1,51 @@
from django.utils.translation import gettext_lazy as _
from common.sdk.im.lark import URL
from common.utils import get_logger
from .feishu import (
FeiShuEnableStartView, FeiShuQRBindView, FeiShuQRBindCallbackView,
FeiShuQRLoginView, FeiShuQRLoginCallbackView
)
logger = get_logger(__file__)
class LarkEnableStartView(FeiShuEnableStartView):
category = 'lark'
class BaseLarkQRMixin:
category = 'lark'
error = _('Lark Error')
error_msg = _('Lark is already bound')
state_session_key = f'_{category}_state'
@property
def url_object(self):
return URL()
class LarkQRBindView(BaseLarkQRMixin, FeiShuQRBindView):
pass
class LarkQRBindCallbackView(BaseLarkQRMixin, FeiShuQRBindCallbackView):
auth_type = 'lark'
auth_type_label = auth_type.capitalize()
client_type_path = f'common.sdk.im.{auth_type}.Lark'
class LarkQRLoginView(BaseLarkQRMixin, FeiShuQRLoginView):
pass
class LarkQRLoginCallbackView(BaseLarkQRMixin, FeiShuQRLoginCallbackView):
user_type = 'lark'
auth_type = user_type
client_type_path = f'common.sdk.im.{auth_type}.Lark'
msg_client_err = _('Lark Error')
msg_user_not_bound_err = _('Lark is not bound')
msg_not_found_user_from_client_err = _('Failed to get user from Lark')
auth_backend = f'AUTH_BACKEND_{auth_type}'.upper()

View File

@ -91,6 +91,12 @@ class UserLoginContextMixin:
'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': 'Lark',
'enabled': settings.AUTH_LARK,
'url': reverse('authentication:lark-qr-login'),
'logo': static('img/login_lark_logo.png')
},
{ {
'name': _('Slack'), 'name': _('Slack'),
'enabled': settings.AUTH_SLACK, 'enabled': settings.AUTH_SLACK,
@ -113,6 +119,10 @@ class UserLoginContextMixin:
'title': '中文(简体)', 'title': '中文(简体)',
'code': 'zh-hans' 'code': 'zh-hans'
}, },
{
'title': '中文(繁體)',
'code': 'zh-hant'
},
{ {
'title': 'English', 'title': 'English',
'code': 'en' 'code': 'en'

View File

@ -6,6 +6,7 @@ from typing import Callable
from django.db import models from django.db import models
from django.db.models.signals import m2m_changed from django.db.models.signals import m2m_changed
from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
@ -19,7 +20,7 @@ from .serializer import SerializerMixin
__all__ = [ __all__ = [
'CommonApiMixin', 'PaginatedResponseMixin', 'RelationMixin', 'CommonApiMixin', 'PaginatedResponseMixin', 'RelationMixin',
'ExtraFilterFieldsMixin', 'ExtraFilterFieldsMixin'
] ]
logger = get_logger(__name__) logger = get_logger(__name__)
@ -89,6 +90,7 @@ class RelationMixin:
class QuerySetMixin: class QuerySetMixin:
action: str action: str
request: Request
get_serializer_class: Callable get_serializer_class: Callable
get_queryset: Callable get_queryset: Callable
@ -98,8 +100,18 @@ class QuerySetMixin:
return queryset return queryset
if self.action == 'metadata': if self.action == 'metadata':
queryset = queryset.none() queryset = queryset.none()
queryset = self.setup_eager_loading(queryset)
return queryset return queryset
# Todo: 未来考虑自定义 pagination
def setup_eager_loading(self, queryset):
if self.request.query_params.get('format') not in ['csv', 'xlsx']:
return queryset
serializer_class = self.get_serializer_class()
if not serializer_class or not hasattr(serializer_class, 'setup_eager_loading'):
return queryset
return serializer_class.setup_eager_loading(queryset)
def paginate_queryset(self, queryset): def paginate_queryset(self, queryset):
page = super().paginate_queryset(queryset) page = super().paginate_queryset(queryset)
serializer_class = self.get_serializer_class() serializer_class = self.get_serializer_class()
@ -186,10 +198,7 @@ class OrderingFielderFieldsMixin:
model = self.queryset.model model = self.queryset.model
else: else:
queryset = self.get_queryset() queryset = self.get_queryset()
if isinstance(queryset, list): model = None if isinstance(queryset, list) else queryset.model
model = None
else:
model = queryset.model
if not model: if not model:
return [] return []

View File

@ -27,6 +27,8 @@ class SerializerMixin:
return None return None
serializer_classes = dict(serializer_classes) serializer_classes = dict(serializer_classes)
view_action = self.request.query_params.get('action') or self.action or 'list' view_action = self.request.query_params.get('action') or self.action or 'list'
if self.request.query_params.get('format'):
view_action = 'retrieve'
serializer_class = serializer_classes.get(view_action) serializer_class = serializer_classes.get(view_action)
if serializer_class is None: if serializer_class is None:

View File

@ -469,7 +469,7 @@ class JSONManyToManyDescriptor:
rule_match = rule.get('match', 'exact') rule_match = rule.get('match', 'exact')
custom_filter_q = None custom_filter_q = None
spec_attr_filter = getattr(to_model, "get_filter_{}_attr_q".format(rule['name']), None) spec_attr_filter = getattr(to_model, "get_{}_filter_attr_q".format(rule['name']), None)
if spec_attr_filter: if spec_attr_filter:
custom_filter_q = spec_attr_filter(rule_value, rule_match) custom_filter_q = spec_attr_filter(rule_value, rule_match)
elif custom_attr_filter: elif custom_attr_filter:
@ -478,59 +478,61 @@ class JSONManyToManyDescriptor:
custom_q &= custom_filter_q custom_q &= custom_filter_q
continue continue
if rule_match == 'in': match rule_match:
res &= value in rule_value or '*' in rule_value case 'in':
elif rule_match == 'exact': res &= value in rule_value or '*' in rule_value
res &= value == rule_value or rule_value == '*' case 'exact':
elif rule_match == 'contains': res &= value == rule_value or rule_value == '*'
res &= (rule_value in value) case 'contains':
elif rule_match == 'startswith': res &= rule_value in value
res &= str(value).startswith(str(rule_value)) case 'startswith':
elif rule_match == 'endswith': res &= str(value).startswith(str(rule_value))
res &= str(value).endswith(str(rule_value)) case 'endswith':
elif rule_match == 'regex': res &= str(value).endswith(str(rule_value))
try: case 'regex':
matched = bool(re.search(r'{}'.format(rule_value), value)) try:
except Exception as e: matched = bool(re.search(r'{}'.format(rule_value), value))
logging.error('Error regex match: %s', e) except Exception as e:
matched = False logging.error('Error regex match: %s', e)
res &= matched matched = False
elif rule_match == 'not': res &= matched
res &= value != rule_value case 'not':
elif rule['match'] == 'gte': res &= value != rule_value
res &= value >= rule_value case 'gte' | 'lte' | 'gt' | 'lt':
elif rule['match'] == 'lte': operations = {
res &= value <= rule_value 'gte': lambda x, y: x >= y,
elif rule['match'] == 'gt': 'lte': lambda x, y: x <= y,
res &= value > rule_value 'gt': lambda x, y: x > y,
elif rule['match'] == 'lt': 'lt': lambda x, y: x < y
res &= value < rule_value }
elif rule['match'] == 'ip_in': res &= operations[rule_match](value, rule_value)
if isinstance(rule_value, str): case 'ip_in':
rule_value = [rule_value] if isinstance(rule_value, str):
res &= '*' in rule_value or contains_ip(value, rule_value) rule_value = [rule_value]
elif rule['match'].startswith('m2m'): res &= '*' in rule_value or contains_ip(value, rule_value)
if isinstance(value, Manager): case rule_match if rule_match.startswith('m2m'):
value = value.values_list('id', flat=True) if isinstance(value, Manager):
elif isinstance(value, QuerySet): value = value.values_list('id', flat=True)
value = value.values_list('id', flat=True) elif isinstance(value, QuerySet):
elif isinstance(value, models.Model): value = value.values_list('id', flat=True)
value = [value.id] elif isinstance(value, models.Model):
if isinstance(rule_value, (str, int)): value = [value.id]
rule_value = [rule_value] if isinstance(rule_value, (str, int)):
value = set(map(str, value)) rule_value = [rule_value]
rule_value = set(map(str, rule_value)) value = set(map(str, value))
rule_value = set(map(str, rule_value))
if rule['match'] == 'm2m_all': if rule['match'] == 'm2m_all':
res &= rule_value.issubset(value) res &= rule_value.issubset(value)
else: else:
res &= bool(value & rule_value) res &= bool(value & rule_value)
else: case __:
logging.error("unknown match: {}".format(rule['match'])) logging.error("unknown match: {}".format(rule['match']))
res &= False res &= False
if not res: if not res:
return res return res
if custom_q: if custom_q:
res &= to_model.objects.filter(custom_q).filter(id=obj.id).exists() res &= to_model.objects.filter(custom_q).filter(id=obj.id).exists()
return res return res

View File

@ -3,6 +3,7 @@
import base64 import base64
import json import json
import logging import logging
from collections import defaultdict
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
@ -12,6 +13,7 @@ from rest_framework import filters
from rest_framework.compat import coreapi, coreschema from rest_framework.compat import coreapi, coreschema
from rest_framework.fields import DateTimeField from rest_framework.fields import DateTimeField
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from rest_framework.filters import OrderingFilter
from common import const from common import const
from common.db.fields import RelatedManager from common.db.fields import RelatedManager
@ -23,6 +25,7 @@ __all__ = [
'IDInFilterBackend', "CustomFilterBackend", 'IDInFilterBackend', "CustomFilterBackend",
"BaseFilterSet", 'IDNotFilterBackend', "BaseFilterSet", 'IDNotFilterBackend',
'NotOrRelFilterBackend', 'LabelFilterBackend', 'NotOrRelFilterBackend', 'LabelFilterBackend',
'RewriteOrderingFilter'
] ]
@ -180,7 +183,7 @@ class LabelFilterBackend(filters.BaseFilterBackend):
] ]
@staticmethod @staticmethod
def parse_label_ids(labels_id): def parse_labels(labels_id):
from labels.models import Label from labels.models import Label
label_ids = [i.strip() for i in labels_id.split(',')] label_ids = [i.strip() for i in labels_id.split(',')]
cleaned = [] cleaned = []
@ -201,8 +204,8 @@ class LabelFilterBackend(filters.BaseFilterBackend):
q = Q() q = Q()
for kwarg in args: for kwarg in args:
q |= Q(**kwarg) q |= Q(**kwarg)
ids = Label.objects.filter(q).values_list('id', flat=True) labels = Label.objects.filter(q)
cleaned.extend(list(ids)) cleaned.extend(list(labels))
return cleaned return cleaned
def filter_queryset(self, request, queryset, view): def filter_queryset(self, request, queryset, view):
@ -221,13 +224,23 @@ class LabelFilterBackend(filters.BaseFilterBackend):
app_label = model._meta.app_label app_label = model._meta.app_label
model_name = model._meta.model_name model_name = model._meta.model_name
resources = labeled_resource_cls.objects.filter( full_resources = labeled_resource_cls.objects.filter(
res_type__app_label=app_label, res_type__model=model_name, res_type__app_label=app_label, res_type__model=model_name,
) )
label_ids = self.parse_label_ids(labels_id) labels = self.parse_labels(labels_id)
resources = model.filter_resources_by_labels(resources, label_ids) grouped = defaultdict(set)
res_ids = resources.values_list('res_id', flat=True) for label in labels:
queryset = queryset.filter(id__in=set(res_ids)) grouped[label.name].add(label.id)
matched_ids = set()
for name, label_ids in grouped.items():
resources = model.filter_resources_by_labels(full_resources, label_ids, rel='any')
res_ids = resources.values_list('res_id', flat=True)
if not matched_ids:
matched_ids = set(res_ids)
else:
matched_ids &= set(res_ids)
queryset = queryset.filter(id__in=matched_ids)
return queryset return queryset
@ -324,3 +337,17 @@ class NotOrRelFilterBackend(filters.BaseFilterBackend):
queryset.query.where.connector = 'OR' queryset.query.where.connector = 'OR'
queryset._result_cache = None queryset._result_cache = None
return queryset return queryset
class RewriteOrderingFilter(OrderingFilter):
default_ordering_if_has = ('name', )
def get_default_ordering(self, view):
ordering = super().get_default_ordering(view)
# 如果 view.ordering = [] 表示不排序, 这样可以节约性能 (比如: 用户授权的资产)
if ordering is not None:
return ordering
ordering_fields = getattr(view, 'ordering_fields', self.ordering_fields)
if ordering_fields:
ordering = tuple([f for f in ordering_fields if f in self.default_ordering_if_has])
return ordering

View File

@ -4,6 +4,7 @@ import re
from datetime import datetime from datetime import datetime
import pyzipper import pyzipper
from django.conf import settings
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.renderers import BaseRenderer from rest_framework.renderers import BaseRenderer
@ -16,7 +17,7 @@ logger = get_logger(__file__)
class BaseFileRenderer(BaseRenderer): class BaseFileRenderer(BaseRenderer):
# 渲染模版标识, 导入、导出、更新模版: ['import', 'update', 'export'] # 渲染模板标识, 导入、导出、更新模板: ['import', 'update', 'export']
template = 'export' template = 'export'
serializer = None serializer = None
@ -77,7 +78,7 @@ class BaseFileRenderer(BaseRenderer):
results = [results[0]] if results else results results = [results[0]] if results else results
else: else:
# 限制数据数量 # 限制数据数量
results = results[:10000] results = results[:settings.MAX_LIMIT_PER_PAGE]
# 会将一些 UUID 字段转化为 string # 会将一些 UUID 字段转化为 string
results = json.loads(json.dumps(results, cls=encoders.JSONEncoder)) results = json.loads(json.dumps(results, cls=encoders.JSONEncoder))
return results return results

View File

@ -23,7 +23,14 @@ class CSVFileRenderer(BaseFileRenderer):
self.writer = csv_writer self.writer = csv_writer
def write_row(self, row): def write_row(self, row):
self.writer.writerow(row) row_escape = []
for d in row:
if isinstance(d, str) and d.strip().startswith(('=', '@')):
d = "'{}".format(d)
row_escape.append(d)
else:
row_escape.append(d)
self.writer.writerow(row_escape)
def get_rendered_value(self): def get_rendered_value(self):
value = self.buffer.getvalue() value = self.buffer.getvalue()

View File

@ -25,7 +25,9 @@ class ExcelFileRenderer(BaseFileRenderer):
# 处理非法字符 # 处理非法字符
column_count += 1 column_count += 1
cell_value = ILLEGAL_CHARACTERS_RE.sub(r'', str(cell_value)) cell_value = ILLEGAL_CHARACTERS_RE.sub(r'', str(cell_value))
self.ws.cell(row=self.row_count, column=column_count, value=str(cell_value)) cell = self.ws.cell(row=self.row_count, column=column_count, value=str(cell_value))
# 设置单元格格式为纯文本, 防止执行公式
cell.data_type = 's'
def after_render(self): def after_render(self):
for col in self.ws.columns: for col in self.ws.columns:

View File

@ -27,7 +27,7 @@ class Services(TextChoices):
cls.flower: services.FlowerService, cls.flower: services.FlowerService,
cls.celery_default: services.CeleryDefaultService, cls.celery_default: services.CeleryDefaultService,
cls.celery_ansible: services.CeleryAnsibleService, cls.celery_ansible: services.CeleryAnsibleService,
cls.beat: services.BeatService cls.beat: services.BeatService,
} }
return services_map.get(name) return services_map.get(name)

View File

@ -12,8 +12,8 @@ class CeleryBaseService(BaseService):
@property @property
def cmd(self): def cmd(self):
print('\n- Start Celery as Distributed Task Queue: {}'.format(self.queue.capitalize())) print('\n- Start Celery as Distributed Task Queue: {}'.format(self.queue.capitalize()))
ansible_config_path = os.path.join(settings.APPS_DIR, 'ops', 'ansible', 'ansible.cfg') ansible_config_path = os.path.join(settings.APPS_DIR, 'libs', 'ansible', 'ansible.cfg')
ansible_modules_path = os.path.join(settings.APPS_DIR, 'ops', 'ansible', 'modules') ansible_modules_path = os.path.join(settings.APPS_DIR, 'libs', 'ansible', 'modules')
os.environ.setdefault('LC_ALL', 'C.UTF-8') os.environ.setdefault('LC_ALL', 'C.UTF-8')
os.environ.setdefault('PYTHONOPTIMIZE', '1') os.environ.setdefault('PYTHONOPTIMIZE', '1')
os.environ.setdefault('ANSIBLE_FORCE_COLOR', 'True') os.environ.setdefault('ANSIBLE_FORCE_COLOR', 'True')

View File

@ -2,24 +2,18 @@ import json
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from django.conf import settings
from users.utils import construct_user_email
from common.utils.common import get_logger
from common.sdk.im.utils import digest
from common.sdk.im.mixin import RequestMixin, BaseRequest from common.sdk.im.mixin import RequestMixin, BaseRequest
from common.sdk.im.utils import digest
from common.utils.common import get_logger
from users.utils import construct_user_email
logger = get_logger(__name__) logger = get_logger(__name__)
class URL: class URL:
# https://open.feishu.cn/document/ukTMukTMukTM/uEDO4UjLxgDO14SM4gTN # https://open.feishu.cn/document/ukTMukTMukTM/uEDO4UjLxgDO14SM4gTN
@property
def host(self): host = 'https://open.feishu.cn'
if settings.FEISHU_VERSION == 'feishu':
h = 'https://open.feishu.cn'
else:
h = 'https://open.larksuite.com'
return h
@property @property
def authen(self): def authen(self):
@ -87,12 +81,13 @@ class FeiShu(RequestMixin):
""" """
非业务数据导致的错误直接抛异常说明是系统配置错误业务代码不用理会 非业务数据导致的错误直接抛异常说明是系统配置错误业务代码不用理会
""" """
requests_cls = FeishuRequests
def __init__(self, app_id, app_secret, timeout=None): def __init__(self, app_id, app_secret, timeout=None):
self._app_id = app_id or '' self._app_id = app_id or ''
self._app_secret = app_secret or '' self._app_secret = app_secret or ''
self._requests = FeishuRequests( self._requests = self.requests_cls(
app_id=app_id, app_id=app_id,
app_secret=app_secret, app_secret=app_secret,
timeout=timeout timeout=timeout
@ -130,7 +125,7 @@ class FeiShu(RequestMixin):
body['receive_id'] = user_id body['receive_id'] = user_id
try: try:
logger.info(f'Feishu send text: user_ids={user_ids} msg={msg}') logger.info(f'{self.__class__.__name__} send text: user_ids={user_ids} msg={msg}')
self._requests.post(URL().send_message, params=params, json=body) self._requests.post(URL().send_message, params=params, json=body)
except APIException as e: except APIException as e:
# 只处理可预知的错误 # 只处理可预知的错误

View File

@ -0,0 +1,16 @@
from common.utils.common import get_logger
from ..feishu import URL as FeiShuURL, FeishuRequests, FeiShu
logger = get_logger(__name__)
class URL(FeiShuURL):
host = 'https://open.larksuite.com'
class LarkRequests(FeishuRequests):
pass
class Lark(FeiShu):
requests_cls = LarkRequests

View File

@ -43,6 +43,7 @@ class CustomSMS(BaseSMSClient):
raise JMSException(detail=response.text, code=response.status_code) raise JMSException(detail=response.text, code=response.status_code)
except Exception as exc: except Exception as exc:
logger.error('Custom sms error: {}'.format(exc)) logger.error('Custom sms error: {}'.format(exc))
raise JMSException(exc)
client = CustomSMS client = CustomSMS

View File

@ -1,16 +1,19 @@
import re import re
from importlib import import_module
from django.conf import settings
from django.contrib.sessions.backends.cache import ( from django.contrib.sessions.backends.cache import (
SessionStore as DjangoSessionStore SessionStore as DjangoSessionStore
) )
from django.core.cache import cache from django.core.cache import cache, caches
from jumpserver.utils import get_current_request from jumpserver.utils import get_current_request
class SessionStore(DjangoSessionStore): class SessionStore(DjangoSessionStore):
ignore_urls = [ ignore_urls = [
r'^/api/v1/users/profile/' r'^/api/v1/users/profile/',
r'^/api/v1/authentication/user-session/'
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -32,10 +35,16 @@ class RedisUserSessionManager:
def add_or_increment(self, session_key): def add_or_increment(self, session_key):
self.client.hincrby(self.JMS_SESSION_KEY, session_key, 1) self.client.hincrby(self.JMS_SESSION_KEY, session_key, 1)
def decrement_or_remove(self, session_key): def decrement(self, session_key):
new_count = self.client.hincrby(self.JMS_SESSION_KEY, session_key, -1) self.client.hincrby(self.JMS_SESSION_KEY, session_key, -1)
if new_count <= 0:
def remove(self, session_key):
try:
self.client.hdel(self.JMS_SESSION_KEY, session_key) self.client.hdel(self.JMS_SESSION_KEY, session_key)
session_store = import_module(settings.SESSION_ENGINE).SessionStore(session_key)
session_store.delete()
except Exception:
pass
def check_active(self, session_key): def check_active(self, session_key):
count = self.client.hget(self.JMS_SESSION_KEY, session_key) count = self.client.hget(self.JMS_SESSION_KEY, session_key)
@ -52,5 +61,12 @@ class RedisUserSessionManager:
session_keys.append(key) session_keys.append(key)
return session_keys return session_keys
@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].iter_keys('*')
return [k.replace(cache_key_prefix, '') for k in keys]
user_session_manager = RedisUserSessionManager() user_session_manager = RedisUserSessionManager()

View File

@ -12,7 +12,7 @@ class ReplayStorageHandler(BaseStorageHandler):
# 获取外部存储路径名 # 获取外部存储路径名
session_path = self.obj.find_ok_relative_path_in_storage(storage) session_path = self.obj.find_ok_relative_path_in_storage(storage)
if not session_path: if not session_path:
return None return None, None
# 通过外部存储路径名后缀,构造真实的本地存储路径 # 通过外部存储路径名后缀,构造真实的本地存储路径
return session_path, self.obj.get_local_path_by_relative_path(session_path) return session_path, self.obj.get_local_path_by_relative_path(session_path)

View File

@ -30,7 +30,7 @@ class SendAndVerifyCodeUtil(object):
self.other_args = kwargs self.other_args = kwargs
def gen_and_send_async(self): def gen_and_send_async(self):
return send_async.delay(self) return send_async.apply_async(kwargs={"sender": self}, priority=100)
def gen_and_send(self): def gen_and_send(self):
ttl = self.__ttl() ttl = self.__ttl()

View File

@ -118,9 +118,14 @@ class DateTimeMixin:
return self.get_logs_queryset_filter(qs, 'date_start') return self.get_logs_queryset_filter(qs, 'date_start')
@lazyproperty @lazyproperty
def command_queryset(self): def command_type_queryset_tuple(self):
qs = Command.objects.all() type_queryset_tuple = Command.get_all_type_queryset_tuple()
return self.get_logs_queryset_filter(qs, 'timestamp', is_timestamp=True) return (
(tp, self.get_logs_queryset_filter(
qs, 'timestamp', is_timestamp=True
))
for tp, qs in type_queryset_tuple
)
@lazyproperty @lazyproperty
def job_logs_queryset(self): def job_logs_queryset(self):
@ -131,7 +136,7 @@ class DateTimeMixin:
class DatesLoginMetricMixin: class DatesLoginMetricMixin:
dates_list: list dates_list: list
date_start_end: tuple date_start_end: tuple
command_queryset: Command.objects command_type_queryset_tuple: tuple
sessions_queryset: Session.objects sessions_queryset: Session.objects
ftp_logs_queryset: FTPLog.objects ftp_logs_queryset: FTPLog.objects
job_logs_queryset: JobLog.objects job_logs_queryset: JobLog.objects
@ -229,13 +234,29 @@ class DatesLoginMetricMixin:
def change_password_logs_amount(self): def change_password_logs_amount(self):
return self.password_change_logs_queryset.count() return self.password_change_logs_queryset.count()
@lazyproperty
def command_statistics(self):
from terminal.const import CommandStorageType
total_amount = 0
danger_amount = 0
for tp, qs in self.command_type_queryset_tuple:
if tp == CommandStorageType.es:
total_amount += qs.count(limit_to_max_result_window=False)
danger_amount += qs.filter(risk_level=RiskLevelChoices.reject).count(limit_to_max_result_window=False)
else:
total_amount += qs.count()
danger_amount += qs.filter(risk_level=RiskLevelChoices.reject).count()
return total_amount, danger_amount
@lazyproperty @lazyproperty
def commands_amount(self): def commands_amount(self):
return self.command_queryset.count() total_amount, __ = self.command_statistics
return total_amount
@lazyproperty @lazyproperty
def commands_danger_amount(self): def commands_danger_amount(self):
return self.command_queryset.filter(risk_level=RiskLevelChoices.reject).count() __, danger_amount = self.command_statistics
return danger_amount
@lazyproperty @lazyproperty
def job_logs_running_amount(self): def job_logs_running_amount(self):

View File

@ -277,6 +277,7 @@ class Config(dict):
'AUTH_LDAP_START_TLS': False, 'AUTH_LDAP_START_TLS': False,
'AUTH_LDAP_USER_ATTR_MAP': {"username": "cn", "name": "sn", "email": "mail"}, 'AUTH_LDAP_USER_ATTR_MAP': {"username": "cn", "name": "sn", "email": "mail"},
'AUTH_LDAP_CONNECT_TIMEOUT': 10, 'AUTH_LDAP_CONNECT_TIMEOUT': 10,
'AUTH_LDAP_CACHE_TIMEOUT': 3600 * 24 * 30,
'AUTH_LDAP_SEARCH_PAGED_SIZE': 1000, 'AUTH_LDAP_SEARCH_PAGED_SIZE': 1000,
'AUTH_LDAP_SYNC_IS_PERIODIC': False, 'AUTH_LDAP_SYNC_IS_PERIODIC': False,
'AUTH_LDAP_SYNC_INTERVAL': None, 'AUTH_LDAP_SYNC_INTERVAL': None,
@ -407,7 +408,11 @@ class Config(dict):
'AUTH_FEISHU': False, 'AUTH_FEISHU': False,
'FEISHU_APP_ID': '', 'FEISHU_APP_ID': '',
'FEISHU_APP_SECRET': '', 'FEISHU_APP_SECRET': '',
'FEISHU_VERSION': 'feishu',
# Lark
'AUTH_LARK': False,
'LARK_APP_ID': '',
'LARK_APP_SECRET': '',
# Slack # Slack
'AUTH_SLACK': False, 'AUTH_SLACK': False,
@ -609,7 +614,11 @@ class Config(dict):
'FILE_UPLOAD_SIZE_LIMIT_MB': 200, 'FILE_UPLOAD_SIZE_LIMIT_MB': 200,
'TICKET_APPLY_ASSET_SCOPE': 'all' 'TICKET_APPLY_ASSET_SCOPE': 'all',
# Ansible Receptor
'ANSIBLE_RECEPTOR_ENABLE': True,
'ANSIBLE_RECEPTOR_SOCK_PATH': '{}/data/share/control.sock'.format(PROJECT_DIR)
} }
old_config_map = { old_config_map = {

View File

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import datetime
from django.conf import settings from django.conf import settings
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -12,17 +14,18 @@ default_interface = dict((
('login_title', _('JumpServer Open Source Bastion Host')), ('login_title', _('JumpServer Open Source Bastion Host')),
('theme', 'classic_green'), ('theme', 'classic_green'),
('theme_info', {}), ('theme_info', {}),
('beian_link', ''), ('footer_content', ''),
('beian_text', '')
)) ))
current_year = datetime.datetime.now().year
default_context = { default_context = {
'DEFAULT_PK': '00000000-0000-0000-0000-000000000000', 'DEFAULT_PK': '00000000-0000-0000-0000-000000000000',
'LOGIN_CAS_logo_logout': static('img/login_cas_logo.png'), 'LOGIN_CAS_logo_logout': static('img/login_cas_logo.png'),
'LOGIN_WECOM_logo_logout': static('img/login_wecom_logo.png'), 'LOGIN_WECOM_logo_logout': static('img/login_wecom_logo.png'),
'LOGIN_DINGTALK_logo_logout': static('img/login_dingtalk_logo.png'), 'LOGIN_DINGTALK_logo_logout': static('img/login_dingtalk_logo.png'),
'LOGIN_FEISHU_logo_logout': static('img/login_feishu_logo.png'), 'LOGIN_FEISHU_logo_logout': static('img/login_feishu_logo.png'),
'COPYRIGHT': 'FIT2CLOUD 飞致云' + ' © 2014-2023', 'COPYRIGHT': f'FIT2CLOUD 飞致云 © 2014-{current_year}',
'INTERFACE': default_interface, 'INTERFACE': default_interface,
} }

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