mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-24 21:12:35 +00:00
Compare commits
148 Commits
v4.10.0
...
v4.10.4-lt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71766418bb | ||
|
|
8f91cb1473 | ||
|
|
b72e8eba7c | ||
|
|
d1d6f3fe9c | ||
|
|
6095c9865f | ||
|
|
6c374cb41f | ||
|
|
df64145adc | ||
|
|
44d77ba03f | ||
|
|
3af188492f | ||
|
|
9e798cd0b6 | ||
|
|
4d22c0722b | ||
|
|
e6a1662780 | ||
|
|
cc4be36752 | ||
|
|
e1f5d3c737 | ||
|
|
c0adc1fe74 | ||
|
|
613715135b | ||
|
|
fe1d5f9828 | ||
|
|
1d375e15c5 | ||
|
|
ac21d260ea | ||
|
|
accde77307 | ||
|
|
c7dcf1ba59 | ||
|
|
b564bbebb3 | ||
|
|
9440c855f4 | ||
|
|
f282b2079e | ||
|
|
1790cd8345 | ||
|
|
7da74dc6e8 | ||
|
|
33b0068f49 | ||
|
|
9a446c118b | ||
|
|
4bf337b2b4 | ||
|
|
2acbb80920 | ||
|
|
ae859c5562 | ||
|
|
a9bc716af5 | ||
|
|
2d5401e76e | ||
|
|
d933e296bc | ||
|
|
1e5a995917 | ||
|
|
baaaf83ab9 | ||
|
|
ab06ac1f1f | ||
|
|
99c4622ccb | ||
|
|
9bdfab966f | ||
|
|
1a1acb62de | ||
|
|
2a128ea01b | ||
|
|
5a720b41bf | ||
|
|
726c5cf34d | ||
|
|
06afc8a0e1 | ||
|
|
276fd928a7 | ||
|
|
05c6272d7e | ||
|
|
c3f877d116 | ||
|
|
60deef2abf | ||
|
|
058754dc1b | ||
|
|
a238c5d34b | ||
|
|
76c6ed0f95 | ||
|
|
0d07f7421b | ||
|
|
b07d4e207c | ||
|
|
dc92963059 | ||
|
|
9abd708a0a | ||
|
|
c9270877eb | ||
|
|
b5518dd2ba | ||
|
|
1d40f5ecbc | ||
|
|
91fee6c034 | ||
|
|
1b65055c5e | ||
|
|
e79ef516a5 | ||
|
|
8843f247d6 | ||
|
|
cb42df542d | ||
|
|
46ddad1d59 | ||
|
|
a9399dd709 | ||
|
|
f55a6ae364 | ||
|
|
34772b8fb4 | ||
|
|
286891061b | ||
|
|
269bf1283e | ||
|
|
d32c11bced | ||
|
|
fb64af2eb2 | ||
|
|
82f32cbba3 | ||
|
|
411b485448 | ||
|
|
60608e92ea | ||
|
|
6922c62b50 | ||
|
|
531c23d983 | ||
|
|
df9e6cf866 | ||
|
|
65f2b92eb3 | ||
|
|
bac621991e | ||
|
|
db24d34b64 | ||
|
|
265c066054 | ||
|
|
dad6b5def0 | ||
|
|
00f6c3a5de | ||
|
|
cfbd162890 | ||
|
|
17e8f25cb4 | ||
|
|
71bf8c8699 | ||
|
|
98342e0b70 | ||
|
|
70aaa9cf8f | ||
|
|
70b2d28760 | ||
|
|
8265a069e2 | ||
|
|
9ec48aae0c | ||
|
|
41658af8fd | ||
|
|
7dfb31840e | ||
|
|
2f55db60ec | ||
|
|
551e6d0479 | ||
|
|
61c54314d7 | ||
|
|
4e7cd37c1d | ||
|
|
e89f43dcd3 | ||
|
|
259ead4c6e | ||
|
|
348b2a833a | ||
|
|
8aec1604ce | ||
|
|
be28a6954a | ||
|
|
79c2284a01 | ||
|
|
c2b44cfd84 | ||
|
|
1e07cba545 | ||
|
|
48a9b2664a | ||
|
|
b3bfbf5046 | ||
|
|
08aa1e48b9 | ||
|
|
97d7427090 | ||
|
|
9f9d5855c4 | ||
|
|
2db8f0f444 | ||
|
|
b75210b0c3 | ||
|
|
4713c6ddf6 | ||
|
|
b70fb58faf | ||
|
|
3991976a00 | ||
|
|
90256208dd | ||
|
|
bbd3b32aa1 | ||
|
|
ec20a4fd02 | ||
|
|
d179ce1cd4 | ||
|
|
caf23f5b05 | ||
|
|
4bb19d59ef | ||
|
|
74ed693a95 | ||
|
|
4a7a1fd95c | ||
|
|
56268433e0 | ||
|
|
ea59677b13 | ||
|
|
94ed26e115 | ||
|
|
284d793253 | ||
|
|
570566d9dd | ||
|
|
3f85c67aee | ||
|
|
53a84850dc | ||
|
|
e4be9621bb | ||
|
|
f8b778ada2 | ||
|
|
5c28b15e39 | ||
|
|
5e0babdba8 | ||
|
|
8a3acb649e | ||
|
|
1ade652381 | ||
|
|
7472f83d7a | ||
|
|
c56a3d0a2e | ||
|
|
1a10225823 | ||
|
|
56c94d7b3c | ||
|
|
16e7a12974 | ||
|
|
1364889083 | ||
|
|
4f19954640 | ||
|
|
1b2e376681 | ||
|
|
14c5162153 | ||
|
|
f9245e17cd | ||
|
|
6bd1ec960b | ||
|
|
77cc02ae60 |
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"printWidth": 100,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -13,6 +13,7 @@ ARG TOOLS=" \
|
||||
nmap \
|
||||
telnet \
|
||||
vim \
|
||||
postgresql-client-13 \
|
||||
wget"
|
||||
|
||||
RUN set -ex \
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
|
||||
[English](/README.md) · [中文(简体)](/readmes/README.zh-hans.md) · [中文(繁體)](/readmes/README.zh-hant.md) · [日本語](/readmes/README.ja.md) · [Português (Brasil)](/readmes/README.pt-br.md) · [Español](/readmes/README.es.md) · [Русский](/readmes/README.ru.md)
|
||||
[English](/README.md) · [中文(简体)](/readmes/README.zh-hans.md) · [中文(繁體)](/readmes/README.zh-hant.md) · [日本語](/readmes/README.ja.md) · [Português (Brasil)](/readmes/README.pt-br.md) · [Español](/readmes/README.es.md) · [Русский](/readmes/README.ru.md) · [한국어](/readmes/README.ko.md)
|
||||
|
||||
</div>
|
||||
<br/>
|
||||
@@ -23,8 +23,8 @@ JumpServer is an open-source Privileged Access Management (PAM) tool that provid
|
||||
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://github.com/user-attachments/assets/dd612f3d-c958-4f84-b164-f31b75454d7f">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/user-attachments/assets/28676212-2bc4-4a9f-ae10-3be9320647e3">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://www.jumpserver.com/images/jumpserver-arch-light.png">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://www.jumpserver.com/images/jumpserver-arch-dark.png">
|
||||
<img src="https://github.com/user-attachments/assets/dd612f3d-c958-4f84-b164-f31b75454d7f" alt="Theme-based Image">
|
||||
</picture>
|
||||
|
||||
@@ -85,6 +85,8 @@ JumpServer consists of multiple key components, which collectively form the func
|
||||
| [Nec](https://github.com/jumpserver/nec) | <img alt="Nec" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE VNC Proxy Connector |
|
||||
| [Facelive](https://github.com/jumpserver/facelive) | <img alt="Facelive" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Facial Recognition |
|
||||
|
||||
## Third-party projects
|
||||
- [jumpserver-grafana-dashboard](https://github.com/acerrah/jumpserver-grafana-dashboard) JumpServer with grafana dashboard
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -78,18 +78,25 @@ class AccountViewSet(OrgBulkModelViewSet):
|
||||
permission_classes=[IsValidUser]
|
||||
)
|
||||
def username_suggestions(self, request, *args, **kwargs):
|
||||
asset_ids = request.data.get('assets', [])
|
||||
raw_asset_ids = request.data.get('assets', [])
|
||||
node_ids = request.data.get('nodes', [])
|
||||
username = request.data.get('username', '')
|
||||
|
||||
accounts = Account.objects.all()
|
||||
asset_ids = set(raw_asset_ids)
|
||||
|
||||
if node_ids:
|
||||
nodes = Node.objects.filter(id__in=node_ids)
|
||||
node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True)
|
||||
asset_ids.extend(node_asset_ids)
|
||||
node_asset_qs = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True)
|
||||
asset_ids |= {str(u) for u in node_asset_qs}
|
||||
|
||||
if asset_ids:
|
||||
accounts = accounts.filter(asset_id__in=list(set(asset_ids)))
|
||||
through = Asset.directory_services.through
|
||||
ds_qs = through.objects.filter(asset_id__in=asset_ids) \
|
||||
.values_list('directoryservice_id', flat=True)
|
||||
asset_ids |= {str(u) for u in ds_qs}
|
||||
accounts = Account.objects.filter(asset_id__in=list(asset_ids))
|
||||
else:
|
||||
accounts = Account.objects.all()
|
||||
|
||||
if username:
|
||||
accounts = accounts.filter(username__icontains=username)
|
||||
|
||||
@@ -43,6 +43,7 @@ class AccountTemplateViewSet(OrgBulkModelViewSet):
|
||||
search_fields = ('username', 'name')
|
||||
serializer_classes = {
|
||||
'default': serializers.AccountTemplateSerializer,
|
||||
'retrieve': serializers.AccountDetailTemplateSerializer,
|
||||
}
|
||||
rbac_perms = {
|
||||
'su_from_account_templates': 'accounts.view_accounttemplate',
|
||||
|
||||
@@ -6,10 +6,13 @@ from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from accounts import serializers
|
||||
from accounts.const import AutomationTypes, ChangeSecretRecordStatusChoice
|
||||
from accounts.filters import ChangeSecretRecordFilterSet
|
||||
from accounts.models import ChangeSecretAutomation, ChangeSecretRecord
|
||||
from accounts.const import (
|
||||
AutomationTypes, ChangeSecretRecordStatusChoice
|
||||
)
|
||||
from accounts.filters import ChangeSecretRecordFilterSet, ChangeSecretStatusFilterSet
|
||||
from accounts.models import ChangeSecretAutomation, ChangeSecretRecord, Account
|
||||
from accounts.tasks import execute_automation_record_task
|
||||
from accounts.utils import account_secret_task_status
|
||||
from authentication.permissions import UserConfirmation, ConfirmType
|
||||
from common.permissions import IsValidLicense
|
||||
from orgs.mixins.api import OrgBulkModelViewSet, OrgGenericViewSet
|
||||
@@ -23,7 +26,7 @@ __all__ = [
|
||||
'ChangeSecretAutomationViewSet', 'ChangeSecretRecordViewSet',
|
||||
'ChangSecretExecutionViewSet', 'ChangSecretAssetsListApi',
|
||||
'ChangSecretRemoveAssetApi', 'ChangSecretAddAssetApi',
|
||||
'ChangSecretNodeAddRemoveApi'
|
||||
'ChangSecretNodeAddRemoveApi', 'ChangeSecretStatusViewSet'
|
||||
]
|
||||
|
||||
|
||||
@@ -94,12 +97,13 @@ class ChangeSecretRecordViewSet(mixins.ListModelMixin, OrgGenericViewSet):
|
||||
def execute(self, request, *args, **kwargs):
|
||||
record_ids = request.data.get('record_ids')
|
||||
records = self.get_queryset().filter(id__in=record_ids)
|
||||
execution_count = records.values_list('execution_id', flat=True).distinct().count()
|
||||
if execution_count != 1:
|
||||
if not records.exists():
|
||||
return Response(
|
||||
{'detail': 'Only one execution is allowed to execute'},
|
||||
{'detail': 'No valid records found'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
record_ids = [str(_id) for _id in records.values_list('id', flat=True)]
|
||||
task = execute_automation_record_task.delay(record_ids, self.tp)
|
||||
return Response({'task': task.id}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -154,3 +158,25 @@ class ChangSecretAddAssetApi(AutomationAddAssetApi):
|
||||
class ChangSecretNodeAddRemoveApi(AutomationNodeAddRemoveApi):
|
||||
model = ChangeSecretAutomation
|
||||
serializer_class = serializers.ChangeSecretUpdateNodeSerializer
|
||||
|
||||
|
||||
class ChangeSecretStatusViewSet(OrgBulkModelViewSet):
|
||||
perm_model = ChangeSecretAutomation
|
||||
filterset_class = ChangeSecretStatusFilterSet
|
||||
serializer_class = serializers.ChangeSecretAccountSerializer
|
||||
search_fields = ('username',)
|
||||
|
||||
permission_classes = [RBACPermission, IsValidLicense]
|
||||
http_method_names = ["get", "delete", "options"]
|
||||
|
||||
def get_queryset(self):
|
||||
account_ids = list(account_secret_task_status.account_ids)
|
||||
return Account.objects.filter(id__in=account_ids).select_related('asset')
|
||||
|
||||
def bulk_destroy(self, request, *args, **kwargs):
|
||||
account_ids = request.data.get('account_ids')
|
||||
if isinstance(account_ids, str):
|
||||
account_ids = [account_ids]
|
||||
for _id in account_ids:
|
||||
account_secret_task_status.clear(_id)
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
|
||||
@@ -5,9 +5,10 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.automations.methods import platform_automation_methods
|
||||
from accounts.const import SSHKeyStrategy, SecretStrategy, SecretType, ChangeSecretRecordStatusChoice
|
||||
from accounts.const import SSHKeyStrategy, SecretStrategy, SecretType, ChangeSecretRecordStatusChoice, \
|
||||
ChangeSecretAccountStatus
|
||||
from accounts.models import BaseAccountQuerySet
|
||||
from accounts.utils import SecretGenerator
|
||||
from accounts.utils import SecretGenerator, account_secret_task_status
|
||||
from assets.automations.base.manager import BasePlaybookManager
|
||||
from assets.const import HostTypes
|
||||
from common.db.utils import safe_atomic_db_connection
|
||||
@@ -36,7 +37,7 @@ class BaseChangeSecretPushManager(AccountBasePlaybookManager):
|
||||
)
|
||||
self.account_ids = self.execution.snapshot['accounts']
|
||||
self.record_map = self.execution.snapshot.get('record_map', {}) # 这个是某个失败的记录重试
|
||||
self.name_recorder_mapper = {} # 做个映射,方便后面处理
|
||||
self.name_record_mapper = {} # 做个映射,方便后面处理
|
||||
|
||||
def gen_account_inventory(self, account, asset, h, path_dir):
|
||||
raise NotImplementedError
|
||||
@@ -112,10 +113,15 @@ class BaseChangeSecretPushManager(AccountBasePlaybookManager):
|
||||
if host.get('error'):
|
||||
return host
|
||||
|
||||
host['check_conn_after_change'] = self.execution.snapshot.get('check_conn_after_change', True)
|
||||
host['ssh_params'] = {}
|
||||
|
||||
accounts = self.get_accounts(account)
|
||||
existing_ids = set(map(str, accounts.values_list('id', flat=True)))
|
||||
missing_ids = set(map(str, self.account_ids)) - existing_ids
|
||||
|
||||
for account_id in missing_ids:
|
||||
self.clear_account_queue_status(account_id)
|
||||
|
||||
error_msg = _("No pending accounts found")
|
||||
if not accounts:
|
||||
print(f'{asset}: {error_msg}')
|
||||
@@ -132,31 +138,50 @@ class BaseChangeSecretPushManager(AccountBasePlaybookManager):
|
||||
for account in accounts:
|
||||
h = deepcopy(host)
|
||||
h['name'] += '(' + account.username + ')' # To distinguish different accounts
|
||||
|
||||
account_status = account_secret_task_status.get_status(account.id)
|
||||
if account_status == ChangeSecretAccountStatus.PROCESSING:
|
||||
h['error'] = f'Account is already being processed, skipping: {account}'
|
||||
inventory_hosts.append(h)
|
||||
continue
|
||||
|
||||
try:
|
||||
h = self.gen_account_inventory(account, asset, h, path_dir)
|
||||
h, record = self.gen_account_inventory(account, asset, h, path_dir)
|
||||
h['check_conn_after_change'] = record.execution.snapshot.get('check_conn_after_change', True)
|
||||
account_secret_task_status.set_status(
|
||||
account.id,
|
||||
ChangeSecretAccountStatus.PROCESSING,
|
||||
metadata={'execution_id': self.execution.id}
|
||||
)
|
||||
except Exception as e:
|
||||
h['error'] = str(e)
|
||||
self.clear_account_queue_status(account.id)
|
||||
|
||||
inventory_hosts.append(h)
|
||||
|
||||
return inventory_hosts
|
||||
|
||||
@staticmethod
|
||||
def save_record(recorder):
|
||||
recorder.save(update_fields=['error', 'status', 'date_finished'])
|
||||
def save_record(record):
|
||||
record.save(update_fields=['error', 'status', 'date_finished'])
|
||||
|
||||
@staticmethod
|
||||
def clear_account_queue_status(account_id):
|
||||
account_secret_task_status.clear(account_id)
|
||||
|
||||
def on_host_success(self, host, result):
|
||||
recorder = self.name_recorder_mapper.get(host)
|
||||
if not recorder:
|
||||
record = self.name_record_mapper.get(host)
|
||||
if not record:
|
||||
return
|
||||
recorder.status = ChangeSecretRecordStatusChoice.success.value
|
||||
recorder.date_finished = timezone.now()
|
||||
record.status = ChangeSecretRecordStatusChoice.success.value
|
||||
record.date_finished = timezone.now()
|
||||
|
||||
account = recorder.account
|
||||
account = record.account
|
||||
if not account:
|
||||
print("Account not found, deleted ?")
|
||||
return
|
||||
|
||||
account.secret = getattr(recorder, 'new_secret', account.secret)
|
||||
account.secret = getattr(record, 'new_secret', account.secret)
|
||||
account.date_updated = timezone.now()
|
||||
account.date_change_secret = timezone.now()
|
||||
account.change_secret_status = ChangeSecretRecordStatusChoice.success
|
||||
@@ -172,16 +197,17 @@ class BaseChangeSecretPushManager(AccountBasePlaybookManager):
|
||||
|
||||
with safe_atomic_db_connection():
|
||||
account.save(update_fields=['secret', 'date_updated', 'date_change_secret', 'change_secret_status'])
|
||||
self.save_record(recorder)
|
||||
self.save_record(record)
|
||||
self.clear_account_queue_status(account.id)
|
||||
|
||||
def on_host_error(self, host, error, result):
|
||||
recorder = self.name_recorder_mapper.get(host)
|
||||
if not recorder:
|
||||
record = self.name_record_mapper.get(host)
|
||||
if not record:
|
||||
return
|
||||
recorder.status = ChangeSecretRecordStatusChoice.failed.value
|
||||
recorder.date_finished = timezone.now()
|
||||
recorder.error = error
|
||||
account = recorder.account
|
||||
record.status = ChangeSecretRecordStatusChoice.failed.value
|
||||
record.date_finished = timezone.now()
|
||||
record.error = error
|
||||
account = record.account
|
||||
if not account:
|
||||
print("Account not found, deleted ?")
|
||||
return
|
||||
@@ -192,12 +218,13 @@ class BaseChangeSecretPushManager(AccountBasePlaybookManager):
|
||||
self.summary['fail_accounts'] += 1
|
||||
self.result['fail_accounts'].append(
|
||||
{
|
||||
"asset": str(recorder.asset),
|
||||
"username": recorder.account.username,
|
||||
"asset": str(record.asset),
|
||||
"username": record.account.username,
|
||||
}
|
||||
)
|
||||
super().on_host_error(host, error, result)
|
||||
|
||||
with safe_atomic_db_connection():
|
||||
account.save(update_fields=['change_secret_status', 'date_change_secret', 'date_updated'])
|
||||
self.save_record(recorder)
|
||||
self.save_record(record)
|
||||
self.clear_account_queue_status(account.id)
|
||||
|
||||
@@ -16,9 +16,9 @@ params:
|
||||
|
||||
i18n:
|
||||
Windows account change secret rdp verify:
|
||||
zh: '使用 Ansible 模块 win_user 执行 Windows 账号改密 RDP 协议测试最后的可连接性'
|
||||
ja: 'Ansibleモジュールwin_userはWindowsアカウントの改密RDPプロトコルテストの最後の接続性を実行する'
|
||||
en: 'Using the Ansible module win_user performs Windows account encryption RDP protocol testing for final connectivity'
|
||||
zh: '使用 Ansible 模块 win_user 执行 Windows 账号改密(最后使用 Python 模块 pyfreerdp 验证账号的可连接性)'
|
||||
ja: 'Ansible モジュール win_user を使用して Windows アカウントのパスワードを変更します (最後に Python モジュール pyfreerdp を使用してアカウントの接続を確認します)'
|
||||
en: 'Use the Ansible module win_user to change the Windows account password (finally use the Python module pyfreerdp to verify the account connectivity)'
|
||||
|
||||
Params groups help text:
|
||||
zh: '请输入用户组,多个用户组使用逗号分隔(需填写已存在的用户组)'
|
||||
|
||||
@@ -30,28 +30,28 @@ class ChangeSecretManager(BaseChangeSecretPushManager):
|
||||
record = self.get_or_create_record(asset, account, h['name'])
|
||||
new_secret, private_key_path = self.handle_ssh_secret(account.secret_type, record.new_secret, path_dir)
|
||||
h = self.gen_inventory(h, account, new_secret, private_key_path, asset)
|
||||
return h
|
||||
return h, record
|
||||
|
||||
def get_or_create_record(self, asset, account, name):
|
||||
asset_account_id = f'{asset.id}-{account.id}'
|
||||
|
||||
if asset_account_id in self.record_map:
|
||||
record_id = self.record_map[asset_account_id]
|
||||
recorder = ChangeSecretRecord.objects.filter(id=record_id).first()
|
||||
record = ChangeSecretRecord.objects.filter(id=record_id).first()
|
||||
else:
|
||||
new_secret = self.get_secret(account)
|
||||
recorder = self.create_record(asset, account, new_secret)
|
||||
record = self.create_record(asset, account, new_secret)
|
||||
|
||||
self.name_recorder_mapper[name] = recorder
|
||||
return recorder
|
||||
self.name_record_mapper[name] = record
|
||||
return record
|
||||
|
||||
def create_record(self, asset, account, new_secret):
|
||||
recorder = ChangeSecretRecord(
|
||||
record = ChangeSecretRecord(
|
||||
asset=asset, account=account, execution=self.execution,
|
||||
old_secret=account.secret, new_secret=new_secret,
|
||||
comment=f'{account.username}@{asset.address}'
|
||||
)
|
||||
return recorder
|
||||
return record
|
||||
|
||||
def check_secret(self):
|
||||
if self.secret_strategy == SecretStrategy.custom \
|
||||
@@ -61,10 +61,10 @@ class ChangeSecretManager(BaseChangeSecretPushManager):
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_summary(recorders):
|
||||
def get_summary(records):
|
||||
total, succeed, failed = 0, 0, 0
|
||||
for recorder in recorders:
|
||||
if recorder.status == ChangeSecretRecordStatusChoice.success.value:
|
||||
for record in records:
|
||||
if record.status == ChangeSecretRecordStatusChoice.success.value:
|
||||
succeed += 1
|
||||
else:
|
||||
failed += 1
|
||||
@@ -73,8 +73,8 @@ class ChangeSecretManager(BaseChangeSecretPushManager):
|
||||
return summary
|
||||
|
||||
def print_summary(self):
|
||||
recorders = list(self.name_recorder_mapper.values())
|
||||
summary = self.get_summary(recorders)
|
||||
records = list(self.name_record_mapper.values())
|
||||
summary = self.get_summary(records)
|
||||
print('\n\n' + '-' * 80)
|
||||
plan_execution_end = _('Plan execution end')
|
||||
print('{} {}\n'.format(plan_execution_end, local_now_filename()))
|
||||
@@ -86,7 +86,7 @@ class ChangeSecretManager(BaseChangeSecretPushManager):
|
||||
if self.secret_type and not self.check_secret():
|
||||
return
|
||||
|
||||
recorders = list(self.name_recorder_mapper.values())
|
||||
records = list(self.name_record_mapper.values())
|
||||
if self.record_map:
|
||||
return
|
||||
|
||||
@@ -98,17 +98,17 @@ class ChangeSecretManager(BaseChangeSecretPushManager):
|
||||
for user in recipients:
|
||||
ChangeSecretReportMsg(user, context).publish()
|
||||
|
||||
if not recorders:
|
||||
if not records:
|
||||
return
|
||||
|
||||
summary = self.get_summary(recorders)
|
||||
self.send_recorder_mail(recipients, recorders, summary)
|
||||
summary = self.get_summary(records)
|
||||
self.send_record_mail(recipients, records, summary)
|
||||
|
||||
def send_recorder_mail(self, recipients, recorders, summary):
|
||||
def send_record_mail(self, recipients, records, summary):
|
||||
name = self.execution.snapshot['name']
|
||||
path = os.path.join(os.path.dirname(settings.BASE_DIR), 'tmp')
|
||||
filename = os.path.join(path, f'{name}-{local_now_filename()}-{time.time()}.xlsx')
|
||||
if not self.create_file(recorders, filename):
|
||||
if not self.create_file(records, filename):
|
||||
return
|
||||
|
||||
for user in recipients:
|
||||
@@ -121,9 +121,9 @@ class ChangeSecretManager(BaseChangeSecretPushManager):
|
||||
os.remove(filename)
|
||||
|
||||
@staticmethod
|
||||
def create_file(recorders, filename):
|
||||
def create_file(records, filename):
|
||||
serializer_cls = ChangeSecretRecordBackUpSerializer
|
||||
serializer = serializer_cls(recorders, many=True)
|
||||
serializer = serializer_cls(records, many=True)
|
||||
|
||||
header = [str(v.label) for v in serializer.child.fields.values()]
|
||||
rows = [[str(i) for i in row.values()] for row in serializer.data]
|
||||
|
||||
@@ -15,11 +15,13 @@ from common.decorators import bulk_create_decorator, bulk_update_decorator
|
||||
from settings.models import LeakPasswords
|
||||
|
||||
|
||||
# 已设置手动 finish
|
||||
@bulk_create_decorator(AccountRisk)
|
||||
def create_risk(data):
|
||||
return AccountRisk(**data)
|
||||
|
||||
|
||||
# 已设置手动 finish
|
||||
@bulk_update_decorator(AccountRisk, update_fields=["details", "status"])
|
||||
def update_risk(risk):
|
||||
return risk
|
||||
@@ -217,6 +219,9 @@ class CheckAccountManager(BaseManager):
|
||||
"details": [{"datetime": now, 'type': 'init'}],
|
||||
})
|
||||
|
||||
create_risk.finish()
|
||||
update_risk.finish()
|
||||
|
||||
def pre_run(self):
|
||||
super().pre_run()
|
||||
self.assets = self.execution.get_all_assets()
|
||||
@@ -264,7 +269,7 @@ class CheckAccountManager(BaseManager):
|
||||
handler.clean()
|
||||
|
||||
def get_report_subject(self):
|
||||
return "Check account report of %s" % self.execution.id
|
||||
return _("Check account report of {}").format(self.execution.id)
|
||||
|
||||
def get_report_template(self):
|
||||
return "accounts/check_account_report.html"
|
||||
|
||||
@@ -30,6 +30,16 @@ common_risk_items = [
|
||||
diff_items = risk_items + common_risk_items
|
||||
|
||||
|
||||
@bulk_create_decorator(AccountRisk)
|
||||
def _create_risk(data):
|
||||
return AccountRisk(**data)
|
||||
|
||||
|
||||
@bulk_update_decorator(AccountRisk, update_fields=["details"])
|
||||
def _update_risk(account):
|
||||
return account
|
||||
|
||||
|
||||
def format_datetime(value):
|
||||
if isinstance(value, timezone.datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -141,25 +151,17 @@ class AnalyseAccountRisk:
|
||||
found = assets_risks.get(key)
|
||||
|
||||
if not found:
|
||||
self._create_risk(dict(**d, details=[detail]))
|
||||
_create_risk(dict(**d, details=[detail]))
|
||||
continue
|
||||
|
||||
found.details.append(detail)
|
||||
self._update_risk(found)
|
||||
|
||||
@bulk_create_decorator(AccountRisk)
|
||||
def _create_risk(self, data):
|
||||
return AccountRisk(**data)
|
||||
|
||||
@bulk_update_decorator(AccountRisk, update_fields=["details"])
|
||||
def _update_risk(self, account):
|
||||
return account
|
||||
_update_risk(found)
|
||||
|
||||
def lost_accounts(self, asset, lost_users):
|
||||
if not self.check_risk:
|
||||
return
|
||||
for user in lost_users:
|
||||
self._create_risk(
|
||||
_create_risk(
|
||||
dict(
|
||||
asset_id=str(asset.id),
|
||||
username=user,
|
||||
@@ -176,7 +178,7 @@ class AnalyseAccountRisk:
|
||||
self._analyse_item_changed(ga, d)
|
||||
if not sys_found:
|
||||
basic = {"asset": asset, "username": d["username"], 'gathered_account': ga}
|
||||
self._create_risk(
|
||||
_create_risk(
|
||||
dict(
|
||||
**basic,
|
||||
risk=RiskChoice.new_found,
|
||||
@@ -388,6 +390,7 @@ class GatherAccountsManager(AccountBasePlaybookManager):
|
||||
self.update_gathered_account(ori_account, d)
|
||||
ori_found = username in ori_users
|
||||
need_analyser_gather_account.append((asset, ga, d, ori_found))
|
||||
# 这里顺序不能调整,risk 外键关联了 gathered_account 主键 id,所以在创建 risk 需要保证 gathered_account 已经创建完成
|
||||
self.create_gathered_account.finish()
|
||||
self.update_gathered_account.finish()
|
||||
for analysis_data in need_analyser_gather_account:
|
||||
@@ -403,6 +406,9 @@ class GatherAccountsManager(AccountBasePlaybookManager):
|
||||
present=True
|
||||
)
|
||||
# 因为有 bulk create, bulk update, 所以这里需要 sleep 一下,等待数据同步
|
||||
_update_risk.finish()
|
||||
_create_risk.finish()
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
def get_report_template(self):
|
||||
|
||||
@@ -20,10 +20,11 @@
|
||||
become_private_key_path: "{{ jms_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) }}"
|
||||
recv_timeout: "{{ params.recv_timeout | default(30) }}"
|
||||
register: ping_info
|
||||
delegate_to: localhost
|
||||
|
||||
- name: Change asset password (paramiko)
|
||||
- name: Push asset password (paramiko)
|
||||
custom_command:
|
||||
login_user: "{{ jms_account.username }}"
|
||||
login_password: "{{ jms_account.secret }}"
|
||||
@@ -39,7 +40,10 @@
|
||||
name: "{{ account.username }}"
|
||||
password: "{{ account.secret }}"
|
||||
commands: "{{ params.commands }}"
|
||||
first_conn_delay_time: "{{ first_conn_delay_time | default(0.5) }}"
|
||||
answers: "{{ params.answers }}"
|
||||
recv_timeout: "{{ params.recv_timeout | default(30) }}"
|
||||
delay_time: "{{ params.delay_time | default(2) }}"
|
||||
prompt: "{{ params.prompt | default('.*') }}"
|
||||
ignore_errors: true
|
||||
when: ping_info is succeeded and check_conn_after_change
|
||||
register: change_info
|
||||
@@ -58,5 +62,6 @@
|
||||
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) }}"
|
||||
recv_timeout: "{{ params.recv_timeout | default(30) }}"
|
||||
delegate_to: localhost
|
||||
when: check_conn_after_change
|
||||
@@ -10,10 +10,30 @@ protocol: ssh
|
||||
priority: 50
|
||||
params:
|
||||
- name: commands
|
||||
type: list
|
||||
type: text
|
||||
label: "{{ 'Params commands label' | trans }}"
|
||||
default: [ '' ]
|
||||
default: ''
|
||||
help_text: "{{ 'Params commands help text' | trans }}"
|
||||
- name: recv_timeout
|
||||
type: int
|
||||
label: "{{ 'Params recv_timeout label' | trans }}"
|
||||
default: 30
|
||||
help_text: "{{ 'Params recv_timeout help text' | trans }}"
|
||||
- name: delay_time
|
||||
type: int
|
||||
label: "{{ 'Params delay_time label' | trans }}"
|
||||
default: 2
|
||||
help_text: "{{ 'Params delay_time help text' | trans }}"
|
||||
- name: prompt
|
||||
type: str
|
||||
label: "{{ 'Params prompt label' | trans }}"
|
||||
default: '.*'
|
||||
help_text: "{{ 'Params prompt help text' | trans }}"
|
||||
- name: answers
|
||||
type: text
|
||||
label: "{{ 'Params answer label' | trans }}"
|
||||
default: '.*'
|
||||
help_text: "{{ 'Params answer help text' | trans }}"
|
||||
|
||||
i18n:
|
||||
SSH account push:
|
||||
@@ -22,11 +42,91 @@ i18n:
|
||||
en: 'Custom push using SSH command line'
|
||||
|
||||
Params commands help text:
|
||||
zh: '自定义命令中如需包含账号的 账号、密码、SSH 连接的用户密码 字段,<br />请使用 {username}、{password}、{login_password}格式,执行任务时会进行替换 。<br />比如针对 Cisco 主机进行改密,一般需要配置五条命令:<br />1. enable<br />2. {login_password}<br />3. configure terminal<br />4. username {username} privilege 0 password {password} <br />5. end'
|
||||
ja: 'カスタム コマンドに SSH 接続用のアカウント番号、パスワード、ユーザー パスワード フィールドを含める必要がある場合は、<br />{ユーザー名}、{パスワード}、{login_password& を使用してください。 # 125; 形式。タスクの実行時に置き換えられます。 <br />たとえば、Cisco ホストのパスワードを変更するには、通常、次の 5 つのコマンドを設定する必要があります:<br />1.enable<br />2.{login_password}<br />3 .ターミナルの設定<br / >4. ユーザー名 {ユーザー名} 権限 0 パスワード {パスワード} <br />5. 終了'
|
||||
en: 'If the custom command needs to include the account number, password, and user password field for SSH connection,<br />Please use {username}, {password}, {login_password&# 125; format, which will be replaced when executing the task. <br />For example, to change the password of a Cisco host, you generally need to configure five commands:<br />1. enable<br />2. {login_password}<br />3. configure terminal<br / >4. username {username} privilege 0 password {password} <br />5. end'
|
||||
zh: |
|
||||
请将命令中的指定位置改成特殊符号 <br />
|
||||
1. 推送账号 -> {username} <br />
|
||||
2. 推送密码 -> {password} <br />
|
||||
3. 登录用户密码 -> {login_password} <br />
|
||||
<strong>多条命令使用换行分割,</strong>执行任务时系统会根据特殊符号替换真实数据。<br />
|
||||
比如针对 Cisco 主机进行推送,一般需要配置五条命令:<br />
|
||||
enable <br />
|
||||
{login_password} <br />
|
||||
configure terminal <br />
|
||||
username {username} privilege 0 password {password} <br />
|
||||
end <br />
|
||||
ja: |
|
||||
コマンド内の指定された位置を特殊記号に変更してください。<br />
|
||||
新しいパスワード(アカウント押す) -> {username} <br />
|
||||
新しいパスワード(パスワード押す) -> {password} <br />
|
||||
ログインユーザーパスワード -> {login_password} <br />
|
||||
<strong>複数のコマンドは改行で区切り、</strong>タスクを実行するときにシステムは特殊記号を使用して実際のデータを置き換えます。<br />
|
||||
例えば、Cisco機器のパスワードを変更する場合、一般的には5つのコマンドを設定する必要があります:<br />
|
||||
enable <br />
|
||||
{login_password} <br />
|
||||
configure terminal <br />
|
||||
username {username} privilege 0 password {password} <br />
|
||||
end <br />
|
||||
en: |
|
||||
Please change the specified positions in the command to special symbols. <br />
|
||||
Change password account -> {username} <br />
|
||||
Change password -> {password} <br />
|
||||
Login user password -> {login_password} <br />
|
||||
<strong>Multiple commands are separated by new lines,</strong> and when executing tasks, <br />
|
||||
the system will replace the special symbols with real data. <br />
|
||||
For example, to push the password for a Cisco device, you generally need to configure five commands: <br />
|
||||
enable <br />
|
||||
{login_password} <br />
|
||||
configure terminal <br />
|
||||
username {username} privilege 0 password {password} <br />
|
||||
end <br />
|
||||
|
||||
Params commands label:
|
||||
zh: '自定义命令'
|
||||
ja: 'カスタムコマンド'
|
||||
en: 'Custom command'
|
||||
|
||||
Params recv_timeout label:
|
||||
zh: '超时时间'
|
||||
ja: 'タイムアウト'
|
||||
en: 'Timeout'
|
||||
|
||||
Params recv_timeout help text:
|
||||
zh: '等待命令结果返回的超时时间(秒)'
|
||||
ja: 'コマンドの結果を待つタイムアウト時間(秒)'
|
||||
en: 'The timeout for waiting for the command result to return (Seconds)'
|
||||
|
||||
Params delay_time label:
|
||||
zh: '延迟发送时间'
|
||||
ja: '遅延送信時間'
|
||||
en: 'Delayed send time'
|
||||
|
||||
Params delay_time help text:
|
||||
zh: '每条命令延迟发送的时间间隔(秒)'
|
||||
ja: '各コマンド送信の遅延間隔(秒)'
|
||||
en: 'Time interval for each command delay in sending (Seconds)'
|
||||
|
||||
Params prompt label:
|
||||
zh: '提示符'
|
||||
ja: 'ヒント'
|
||||
en: 'Prompt'
|
||||
|
||||
Params prompt help text:
|
||||
zh: '终端连接后显示的提示符信息(正则表达式)'
|
||||
ja: 'ターミナル接続後に表示されるプロンプト情報(正規表現)'
|
||||
en: 'Prompt information displayed after terminal connection (Regular expression)'
|
||||
|
||||
Params answer label:
|
||||
zh: '命令结果'
|
||||
ja: 'コマンド結果'
|
||||
en: 'Command result'
|
||||
|
||||
Params answer help text:
|
||||
zh: |
|
||||
根据结果匹配度决定是否执行下一条命令,输入框的内容和上方 “自定义命令” 内容按行一一对应(正则表达式)
|
||||
ja: |
|
||||
結果の一致度に基づいて次のコマンドを実行するかどうかを決定します。
|
||||
入力欄の内容は、上の「カスタムコマンド」の内容と行ごとに対応しています(せいきひょうげん)
|
||||
en: |
|
||||
Decide whether to execute the next command based on the result match.
|
||||
The input content corresponds line by line with the content
|
||||
of the `Custom command` above. (Regular expression)
|
||||
|
||||
@@ -12,7 +12,7 @@ logger = get_logger(__name__)
|
||||
class PushAccountManager(BaseChangeSecretPushManager):
|
||||
|
||||
@staticmethod
|
||||
def require_update_version(account, recorder):
|
||||
def require_update_version(account, record):
|
||||
account.skip_history_when_saving = True
|
||||
return False
|
||||
|
||||
@@ -31,29 +31,29 @@ class PushAccountManager(BaseChangeSecretPushManager):
|
||||
secret_type = account.secret_type
|
||||
if not secret:
|
||||
raise ValueError(_('Secret cannot be empty'))
|
||||
self.get_or_create_record(asset, account, h['name'])
|
||||
record = self.get_or_create_record(asset, account, h['name'])
|
||||
new_secret, private_key_path = self.handle_ssh_secret(secret_type, secret, path_dir)
|
||||
h = self.gen_inventory(h, account, new_secret, private_key_path, asset)
|
||||
return h
|
||||
return h, record
|
||||
|
||||
def get_or_create_record(self, asset, account, name):
|
||||
asset_account_id = f'{asset.id}-{account.id}'
|
||||
|
||||
if asset_account_id in self.record_map:
|
||||
record_id = self.record_map[asset_account_id]
|
||||
recorder = PushSecretRecord.objects.filter(id=record_id).first()
|
||||
record = PushSecretRecord.objects.filter(id=record_id).first()
|
||||
else:
|
||||
recorder = self.create_record(asset, account)
|
||||
record = self.create_record(asset, account)
|
||||
|
||||
self.name_recorder_mapper[name] = recorder
|
||||
return recorder
|
||||
self.name_record_mapper[name] = record
|
||||
return record
|
||||
|
||||
def create_record(self, asset, account):
|
||||
recorder = PushSecretRecord(
|
||||
record = PushSecretRecord(
|
||||
asset=asset, account=account, execution=self.execution,
|
||||
comment=f'{account.username}@{asset.address}'
|
||||
)
|
||||
return recorder
|
||||
return record
|
||||
|
||||
def print_summary(self):
|
||||
print('\n\n' + '-' * 80)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
vars:
|
||||
ansible_shell_type: sh
|
||||
ansible_connection: local
|
||||
ansible_python_interpreter: /opt/py3/bin/python
|
||||
ansible_python_interpreter: "{{ local_python_interpreter }}"
|
||||
|
||||
tasks:
|
||||
- name: Verify account (pyfreerdp)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
ansible_user: "{{ account.username }}"
|
||||
ansible_password: "{{ account.secret }}"
|
||||
ansible_ssh_private_key_file: "{{ account.private_key_path }}"
|
||||
ansible_timeout: 30
|
||||
when: not account.become.ansible_become
|
||||
|
||||
- name: Verify account connectivity(Switch)
|
||||
@@ -20,4 +21,5 @@
|
||||
ansible_become_method: "{{ account.become.ansible_become_method }}"
|
||||
ansible_become_user: "{{ account.become.ansible_become_user }}"
|
||||
ansible_become_password: "{{ account.become.ansible_become_password }}"
|
||||
ansible_timeout: 30
|
||||
when: account.become.ansible_become
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
vars:
|
||||
ansible_user: "{{ account.full_username }}"
|
||||
ansible_password: "{{ account.secret }}"
|
||||
ansible_timeout: 30
|
||||
|
||||
@@ -17,7 +17,7 @@ __all__ = [
|
||||
'AutomationTypes', 'SecretStrategy', 'SSHKeyStrategy', 'Connectivity',
|
||||
'DEFAULT_PASSWORD_LENGTH', 'DEFAULT_PASSWORD_RULES', 'TriggerChoice',
|
||||
'PushAccountActionChoice', 'AccountBackupType', 'ChangeSecretRecordStatusChoice',
|
||||
'GatherAccountDetailField'
|
||||
'GatherAccountDetailField', 'ChangeSecretAccountStatus'
|
||||
]
|
||||
|
||||
|
||||
@@ -117,6 +117,12 @@ class ChangeSecretRecordStatusChoice(models.TextChoices):
|
||||
pending = 'pending', _('Pending')
|
||||
|
||||
|
||||
class ChangeSecretAccountStatus(models.TextChoices):
|
||||
QUEUED = 'queued', _('Queued')
|
||||
READY = 'ready', _('Ready')
|
||||
PROCESSING = 'processing', _('Processing')
|
||||
|
||||
|
||||
class GatherAccountDetailField(models.TextChoices):
|
||||
can_login = 'can_login', _('Can login')
|
||||
superuser = 'superuser', _('Superuser')
|
||||
|
||||
@@ -17,6 +17,7 @@ from common.utils.timezone import local_zero_hour, local_now
|
||||
from .const.automation import ChangeSecretRecordStatusChoice
|
||||
from .models import Account, GatheredAccount, ChangeSecretRecord, PushSecretRecord, IntegrationApplication, \
|
||||
AutomationExecution
|
||||
from .utils import account_secret_task_status
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -233,7 +234,7 @@ class AutomationExecutionFilterSet(DaysExecutionFilterMixin, BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = AutomationExecution
|
||||
fields = ["days", 'trigger', 'automation_id', 'automation__name']
|
||||
fields = ["days", 'trigger', 'automation__name']
|
||||
|
||||
|
||||
class PushAccountRecordFilterSet(SecretRecordMixin, UUIDFilterMixin, BaseFilterSet):
|
||||
@@ -242,3 +243,25 @@ class PushAccountRecordFilterSet(SecretRecordMixin, UUIDFilterMixin, BaseFilterS
|
||||
class Meta:
|
||||
model = PushSecretRecord
|
||||
fields = ["id", "status", "asset_id", "execution_id"]
|
||||
|
||||
|
||||
class ChangeSecretStatusFilterSet(BaseFilterSet):
|
||||
asset_name = drf_filters.CharFilter(
|
||||
field_name="asset__name", lookup_expr="icontains"
|
||||
)
|
||||
status = drf_filters.CharFilter(method='filter_dynamic')
|
||||
execution_id = drf_filters.CharFilter(method='filter_dynamic')
|
||||
|
||||
class Meta:
|
||||
model = Account
|
||||
fields = ["username"]
|
||||
|
||||
@staticmethod
|
||||
def filter_dynamic(queryset, name, value):
|
||||
_ids = list(queryset.values_list('id', flat=True))
|
||||
data_map = {
|
||||
_id: account_secret_task_status.get(str(_id)).get(name)
|
||||
for _id in _ids
|
||||
}
|
||||
matched = [_id for _id, v in data_map.items() if v == value]
|
||||
return queryset.filter(id__in=matched)
|
||||
|
||||
@@ -335,6 +335,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
"verbose_name": "Check engine",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
|
||||
@@ -119,6 +119,9 @@ class CheckAccountEngine(JMSBaseModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Check engine')
|
||||
|
||||
@staticmethod
|
||||
def get_default_engines():
|
||||
data = [
|
||||
@@ -128,7 +131,7 @@ class CheckAccountEngine(JMSBaseModel):
|
||||
"name": _("Check the discovered accounts"),
|
||||
"comment": _(
|
||||
"Perform checks and analyses based on automatically discovered account results, "
|
||||
"including user groups, public keys, sudoers, and other information"
|
||||
"including user groups, public keys, sudoers, and other information."
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -144,13 +147,13 @@ class CheckAccountEngine(JMSBaseModel):
|
||||
"id": "00000000-0000-0000-0000-000000000003",
|
||||
"slug": "check_account_repeat",
|
||||
"name": _("Check if the account and password are repeated"),
|
||||
"comment": _("Check if the account is the same as other accounts")
|
||||
"comment": _("Check if the account is the same as other accounts.")
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000004",
|
||||
"slug": "check_account_leak",
|
||||
"name": _("Check whether the account password is a common password"),
|
||||
"comment": _("Check whether the account password is a commonly leaked password")
|
||||
"comment": _("Check whether the account password is a commonly leaked password.")
|
||||
},
|
||||
]
|
||||
return data
|
||||
|
||||
@@ -6,6 +6,7 @@ from common.tasks import send_mail_attachment_async, upload_backup_to_obj_storag
|
||||
from notifications.notifications import UserMessage
|
||||
from terminal.models.component.storage import ReplayStorage
|
||||
from users.models import User
|
||||
from users.utils import activate_user_language
|
||||
|
||||
|
||||
class AccountBackupExecutionTaskMsg:
|
||||
@@ -28,9 +29,10 @@ class AccountBackupExecutionTaskMsg:
|
||||
).format(name)
|
||||
|
||||
def publish(self, attachment_list=None):
|
||||
send_mail_attachment_async(
|
||||
self.subject, self.message, [self.user.email], attachment_list
|
||||
)
|
||||
with activate_user_language(self.user):
|
||||
send_mail_attachment_async(
|
||||
self.subject, self.message, [self.user.email], attachment_list
|
||||
)
|
||||
|
||||
|
||||
class AccountBackupByObjStorageExecutionTaskMsg:
|
||||
@@ -74,9 +76,10 @@ class ChangeSecretExecutionTaskMsg:
|
||||
return self.summary + '\n' + default_message
|
||||
|
||||
def publish(self, attachments=None):
|
||||
send_mail_attachment_async(
|
||||
self.subject, self.message, [self.user.email], attachments
|
||||
)
|
||||
with activate_user_language(self.user):
|
||||
send_mail_attachment_async(
|
||||
self.subject, self.message, [self.user.email], attachments
|
||||
)
|
||||
|
||||
|
||||
class GatherAccountChangeMsg(UserMessage):
|
||||
|
||||
@@ -23,7 +23,7 @@ TYPE_CHOICES = [
|
||||
("delete_both", _("Delete remote")),
|
||||
("add_account", _("Add account")),
|
||||
("change_password_add", _("Change password and Add")),
|
||||
("change_password", _("Change password")),
|
||||
("change_password", _("Change secret")),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -246,6 +246,7 @@ class AccountSerializer(AccountCreateUpdateSerializerMixin, BaseAccountSerialize
|
||||
'source', 'source_id', 'secret_reset',
|
||||
] + AccountCreateUpdateSerializerMixin.Meta.fields + automation_fields
|
||||
read_only_fields = BaseAccountSerializer.Meta.read_only_fields + automation_fields
|
||||
fields = [f for f in fields if f not in ['spec_info']]
|
||||
extra_kwargs = {
|
||||
**BaseAccountSerializer.Meta.extra_kwargs,
|
||||
'name': {'required': False},
|
||||
@@ -268,7 +269,7 @@ class AccountDetailSerializer(AccountSerializer):
|
||||
|
||||
class Meta(AccountSerializer.Meta):
|
||||
model = Account
|
||||
fields = AccountSerializer.Meta.fields + ['has_secret']
|
||||
fields = AccountSerializer.Meta.fields + ['has_secret', 'spec_info']
|
||||
read_only_fields = AccountSerializer.Meta.read_only_fields + ['has_secret']
|
||||
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class BaseAccountSerializer(
|
||||
fields_mini = ["id", "name", "username"]
|
||||
fields_small = fields_mini + [
|
||||
"secret_type", "secret", "passphrase",
|
||||
"privileged", "is_active", "spec_info",
|
||||
"privileged", "is_active",
|
||||
]
|
||||
fields_other = ["created_by", "date_created", "date_updated", "comment"]
|
||||
fields = fields_small + fields_other + ["labels"]
|
||||
|
||||
@@ -57,11 +57,15 @@ class AccountTemplateSerializer(BaseAccountSerializer):
|
||||
fields_unimport_template = ['push_params']
|
||||
|
||||
|
||||
class AccountTemplateSecretSerializer(SecretReadableMixin, AccountTemplateSerializer):
|
||||
class AccountDetailTemplateSerializer(AccountTemplateSerializer):
|
||||
class Meta(AccountTemplateSerializer.Meta):
|
||||
fields = AccountTemplateSerializer.Meta.fields + ['spec_info']
|
||||
|
||||
|
||||
class AccountTemplateSecretSerializer(SecretReadableMixin, AccountDetailTemplateSerializer):
|
||||
class Meta(AccountDetailTemplateSerializer.Meta):
|
||||
fields = AccountDetailTemplateSerializer.Meta.fields
|
||||
extra_kwargs = {
|
||||
**AccountTemplateSerializer.Meta.extra_kwargs,
|
||||
**AccountDetailTemplateSerializer.Meta.extra_kwargs,
|
||||
'secret': {'write_only': False},
|
||||
'spec_info': {'label': _('Spec info')},
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.const import AutomationTypes
|
||||
from accounts.const import AutomationTypes, AccountBackupType
|
||||
from accounts.models import BackupAccountAutomation
|
||||
from common.serializers.fields import EncryptedField
|
||||
from common.utils import get_logger
|
||||
@@ -41,6 +42,17 @@ class BackupAccountSerializer(BaseAutomationSerializer):
|
||||
'types': {'label': _('Asset type')}
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.set_backup_type_choices()
|
||||
|
||||
def set_backup_type_choices(self):
|
||||
field_backup_type = self.fields.get("backup_type")
|
||||
if not field_backup_type:
|
||||
return
|
||||
if not settings.XPACK_LICENSE_IS_VALID:
|
||||
field_backup_type._choices.pop(AccountBackupType.object_storage, None)
|
||||
|
||||
@property
|
||||
def model_type(self):
|
||||
return AutomationTypes.backup_account
|
||||
|
||||
@@ -16,6 +16,7 @@ from assets.models import Asset
|
||||
from common.serializers.fields import LabeledChoiceField, ObjectRelatedField
|
||||
from common.utils import get_logger
|
||||
from .base import BaseAutomationSerializer
|
||||
from ...utils import account_secret_task_status
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
@@ -26,6 +27,7 @@ __all__ = [
|
||||
'ChangeSecretRecordBackUpSerializer',
|
||||
'ChangeSecretUpdateAssetSerializer',
|
||||
'ChangeSecretUpdateNodeSerializer',
|
||||
'ChangeSecretAccountSerializer'
|
||||
]
|
||||
|
||||
|
||||
@@ -179,3 +181,24 @@ class ChangeSecretUpdateNodeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ChangeSecretAutomation
|
||||
fields = ['id', 'nodes']
|
||||
|
||||
|
||||
class ChangeSecretAccountSerializer(serializers.ModelSerializer):
|
||||
asset = ObjectRelatedField(
|
||||
queryset=Asset.objects.all(), required=False, label=_("Asset")
|
||||
)
|
||||
ttl = serializers.SerializerMethodField(label=_('TTL'))
|
||||
meta = serializers.SerializerMethodField(label=_('Meta'))
|
||||
|
||||
class Meta:
|
||||
model = Account
|
||||
fields = ['id', 'username', 'asset', 'meta', 'ttl']
|
||||
read_only_fields = fields
|
||||
|
||||
@staticmethod
|
||||
def get_meta(obj):
|
||||
return account_secret_task_status.get(str(obj.id))
|
||||
|
||||
@staticmethod
|
||||
def get_ttl(obj):
|
||||
return account_secret_task_status.get_ttl(str(obj.id))
|
||||
|
||||
@@ -28,7 +28,7 @@ class DiscoverAccountAutomationSerializer(BaseAutomationSerializer):
|
||||
+ read_only_fields)
|
||||
extra_kwargs = {
|
||||
'check_risk': {
|
||||
'help_text': _('Whether to check the risk of the gathered accounts.'),
|
||||
'help_text': _('Whether to check the risk of the discovered accounts.'),
|
||||
},
|
||||
**BaseAutomationSerializer.Meta.extra_kwargs
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
from celery import shared_task
|
||||
from django.db.models import Q
|
||||
@@ -72,24 +73,43 @@ def execute_automation_record_task(record_ids, tp):
|
||||
task_name = gettext_noop('Execute automation record')
|
||||
|
||||
with tmp_to_root_org():
|
||||
records = ChangeSecretRecord.objects.filter(id__in=record_ids)
|
||||
records = ChangeSecretRecord.objects.filter(id__in=record_ids).order_by('-date_updated')
|
||||
|
||||
if not records:
|
||||
logger.error('No automation record found: {}'.format(record_ids))
|
||||
logger.error(f'No automation record found: {record_ids}')
|
||||
return
|
||||
|
||||
record = records[0]
|
||||
record_map = {f'{record.asset_id}-{record.account_id}': str(record.id) for record in records}
|
||||
task_snapshot = {
|
||||
'params': {},
|
||||
'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(record.execution.org_id):
|
||||
quickstart_automation_by_snapshot(task_name, tp, task_snapshot)
|
||||
seen_accounts = set()
|
||||
unique_records = []
|
||||
for rec in records:
|
||||
acct = str(rec.account_id)
|
||||
if acct not in seen_accounts:
|
||||
seen_accounts.add(acct)
|
||||
unique_records.append(rec)
|
||||
|
||||
exec_groups = defaultdict(list)
|
||||
for rec in unique_records:
|
||||
exec_groups[rec.execution_id].append(rec)
|
||||
|
||||
for __, group in exec_groups.items():
|
||||
latest_rec = group[0]
|
||||
snapshot = getattr(latest_rec.execution, 'snapshot', {}) or {}
|
||||
|
||||
record_map = {f"{r.asset_id}-{r.account_id}": str(r.id) for r in group}
|
||||
assets = [str(r.asset_id) for r in group]
|
||||
accounts = [str(r.account_id) for r in group]
|
||||
|
||||
task_snapshot = {
|
||||
'params': {},
|
||||
'record_map': record_map,
|
||||
'secret': latest_rec.new_secret,
|
||||
'secret_type': snapshot.get('secret_type'),
|
||||
'assets': assets,
|
||||
'accounts': accounts,
|
||||
}
|
||||
|
||||
with tmp_to_org(latest_rec.execution.org_id):
|
||||
quickstart_automation_by_snapshot(task_name, tp, task_snapshot)
|
||||
|
||||
|
||||
@shared_task(
|
||||
|
||||
@@ -1,37 +1,107 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from celery import shared_task
|
||||
from django.utils.translation import gettext_noop, gettext_lazy as _
|
||||
|
||||
from accounts.const import AutomationTypes
|
||||
from accounts.const import AutomationTypes, ChangeSecretAccountStatus
|
||||
from accounts.tasks.common import quickstart_automation_by_snapshot
|
||||
from accounts.utils import account_secret_task_status
|
||||
from common.utils import get_logger
|
||||
from orgs.utils import tmp_to_org
|
||||
|
||||
logger = get_logger(__file__)
|
||||
__all__ = [
|
||||
'push_accounts_to_assets_task',
|
||||
'push_accounts_to_assets_task', 'change_secret_accounts_to_assets_task'
|
||||
]
|
||||
|
||||
|
||||
def _process_accounts(account_ids, automation_model, default_name, automation_type, snapshot=None):
|
||||
from accounts.models import Account
|
||||
accounts = Account.objects.filter(id__in=account_ids)
|
||||
if not accounts:
|
||||
logger.warning(
|
||||
"No accounts found for automation task %s with ids %s",
|
||||
automation_type, account_ids
|
||||
)
|
||||
return
|
||||
|
||||
task_name = automation_model.generate_unique_name(gettext_noop(default_name))
|
||||
snapshot = snapshot or {}
|
||||
snapshot.update({
|
||||
'accounts': [str(a.id) for a in accounts],
|
||||
'assets': [str(a.asset_id) for a in accounts],
|
||||
})
|
||||
|
||||
quickstart_automation_by_snapshot(task_name, automation_type, snapshot)
|
||||
|
||||
|
||||
@shared_task(
|
||||
queue="ansible",
|
||||
verbose_name=_('Push accounts to assets'),
|
||||
activity_callback=lambda self, account_ids, *args, **kwargs: (account_ids, None),
|
||||
description=_(
|
||||
"When creating or modifying an account requires account push, this task is executed"
|
||||
"Whenever an account is created or modified and needs pushing to assets, run this task"
|
||||
)
|
||||
)
|
||||
def push_accounts_to_assets_task(account_ids, params=None):
|
||||
from accounts.models import PushAccountAutomation
|
||||
from accounts.models import Account
|
||||
|
||||
accounts = Account.objects.filter(id__in=account_ids)
|
||||
task_name = gettext_noop("Push accounts to assets")
|
||||
task_name = PushAccountAutomation.generate_unique_name(task_name)
|
||||
|
||||
task_snapshot = {
|
||||
'accounts': [str(account.id) for account in accounts],
|
||||
'assets': [str(account.asset_id) for account in accounts],
|
||||
snapshot = {
|
||||
'params': params or {},
|
||||
}
|
||||
_process_accounts(
|
||||
account_ids,
|
||||
PushAccountAutomation,
|
||||
_("Push accounts to assets"),
|
||||
AutomationTypes.push_account,
|
||||
snapshot=snapshot
|
||||
)
|
||||
|
||||
tp = AutomationTypes.push_account
|
||||
quickstart_automation_by_snapshot(task_name, tp, task_snapshot)
|
||||
|
||||
@shared_task(
|
||||
queue="ansible",
|
||||
verbose_name=_('Change secret accounts to assets'),
|
||||
activity_callback=lambda self, account_ids, *args, **kwargs: (account_ids, None),
|
||||
description=_(
|
||||
"When a secret on an account changes and needs pushing to assets, run this task"
|
||||
)
|
||||
)
|
||||
def change_secret_accounts_to_assets_task(account_ids, params=None, snapshot=None, trigger='manual'):
|
||||
from accounts.models import ChangeSecretAutomation, Account
|
||||
|
||||
manager = account_secret_task_status
|
||||
|
||||
if trigger == 'delay':
|
||||
for _id in manager.account_ids:
|
||||
status = manager.get_status(_id)
|
||||
# Check if the account is in QUEUED status
|
||||
if status == ChangeSecretAccountStatus.QUEUED:
|
||||
account_ids.append(_id)
|
||||
manager.set_status(_id, ChangeSecretAccountStatus.READY)
|
||||
|
||||
if not account_ids:
|
||||
return
|
||||
|
||||
accounts = Account.objects.filter(id__in=account_ids)
|
||||
if not accounts:
|
||||
logger.warning(
|
||||
"No accounts found for change secret automation task with ids %s",
|
||||
account_ids
|
||||
)
|
||||
return
|
||||
|
||||
grouped_ids = defaultdict(lambda: defaultdict(list))
|
||||
for account in accounts:
|
||||
grouped_ids[account.org_id][account.secret_type].append(str(account.id))
|
||||
|
||||
snapshot = snapshot or {}
|
||||
for org_id, secret_map in grouped_ids.items():
|
||||
with tmp_to_org(org_id):
|
||||
for secret_type, ids in secret_map.items():
|
||||
snapshot['secret_type'] = secret_type
|
||||
_process_accounts(
|
||||
ids,
|
||||
ChangeSecretAutomation,
|
||||
_("Change secret accounts to assets"),
|
||||
AutomationTypes.change_secret,
|
||||
snapshot=snapshot
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ router.register(r'account-template-secrets', api.AccountTemplateSecretsViewSet,
|
||||
router.register(r'account-backup-plans', api.BackupAccountViewSet, 'account-backup')
|
||||
router.register(r'account-backup-plan-executions', api.BackupAccountExecutionViewSet, 'account-backup-execution')
|
||||
router.register(r'change-secret-automations', api.ChangeSecretAutomationViewSet, 'change-secret-automation')
|
||||
router.register(r'change-secret-status', api.ChangeSecretStatusViewSet, 'change-secret-status')
|
||||
router.register(r'change-secret-executions', api.ChangSecretExecutionViewSet, 'change-secret-execution')
|
||||
router.register(r'change-secret-records', api.ChangeSecretRecordViewSet, 'change-secret-record')
|
||||
router.register(r'gather-account-automations', api.DiscoverAccountsAutomationViewSet, 'gather-account-automation')
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import copy
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from accounts.const import SecretType, DEFAULT_PASSWORD_RULES
|
||||
|
||||
from common.utils import ssh_key_gen, random_string
|
||||
from common.utils import validate_ssh_private_key, parse_ssh_private_key_str
|
||||
|
||||
@@ -61,3 +62,80 @@ def validate_ssh_key(ssh_key, passphrase=None):
|
||||
if not valid:
|
||||
raise serializers.ValidationError(_("private key invalid or passphrase error"))
|
||||
return parse_ssh_private_key_str(ssh_key, passphrase)
|
||||
|
||||
|
||||
class AccountSecretTaskStatus:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prefix='queue:change_secret:',
|
||||
debounce_key='debounce:change_secret:task',
|
||||
debounce_timeout=10,
|
||||
queue_status_timeout=60,
|
||||
default_timeout=3600,
|
||||
delayed_task_countdown=20,
|
||||
):
|
||||
self.prefix = prefix
|
||||
self.debounce_key = debounce_key
|
||||
self.debounce_timeout = debounce_timeout
|
||||
self.queue_status_timeout = queue_status_timeout
|
||||
self.default_timeout = default_timeout
|
||||
self.delayed_task_countdown = delayed_task_countdown
|
||||
self.enabled = getattr(settings, 'CHANGE_SECRET_AFTER_SESSION_END', False)
|
||||
|
||||
def _key(self, identifier):
|
||||
return f"{self.prefix}{identifier}"
|
||||
|
||||
@property
|
||||
def account_ids(self):
|
||||
for key in cache.iter_keys(f"{self.prefix}*"):
|
||||
yield key.split(':')[-1]
|
||||
|
||||
def is_debounced(self):
|
||||
return cache.add(self.debounce_key, True, self.debounce_timeout)
|
||||
|
||||
def get_queue_key(self, identifier):
|
||||
return self._key(identifier)
|
||||
|
||||
def set_status(
|
||||
self,
|
||||
identifier,
|
||||
status,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
use_add=False
|
||||
):
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
key = self._key(identifier)
|
||||
data = {"status": status}
|
||||
if metadata:
|
||||
data.update(metadata)
|
||||
|
||||
if use_add:
|
||||
return cache.add(key, data, timeout or self.queue_status_timeout)
|
||||
|
||||
cache.set(key, data, timeout or self.default_timeout)
|
||||
|
||||
def get(self, identifier):
|
||||
return cache.get(self._key(identifier), {})
|
||||
|
||||
def get_status(self, identifier):
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
record = cache.get(self._key(identifier), {})
|
||||
return record.get("status")
|
||||
|
||||
def get_ttl(self, identifier):
|
||||
return cache.ttl(self._key(identifier))
|
||||
|
||||
def clear(self, identifier):
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
cache.delete(self._key(identifier))
|
||||
|
||||
|
||||
account_secret_task_status = AccountSecretTaskStatus()
|
||||
|
||||
@@ -9,5 +9,6 @@ class ActionChoices(models.TextChoices):
|
||||
warning = 'warning', _('Warn')
|
||||
notice = 'notice', _('Notify')
|
||||
notify_and_warn = 'notify_and_warn', _('Prompt and warn')
|
||||
face_verify = 'face_verify', _('Face Verify')
|
||||
face_online = 'face_online', _('Face Online')
|
||||
face_verify = 'face_verify', _('Face verify')
|
||||
face_online = 'face_online', _('Face online')
|
||||
change_secret = 'change_secret', _('Secret rotation')
|
||||
|
||||
@@ -79,6 +79,8 @@ class ActionAclSerializer(serializers.Serializer):
|
||||
field_action._choices.pop(ActionChoices.face_online, None)
|
||||
for choice in self.Meta.action_choices_exclude:
|
||||
field_action._choices.pop(choice, None)
|
||||
if not settings.XPACK_LICENSE_IS_VALID or not settings.CHANGE_SECRET_AFTER_SESSION_END:
|
||||
field_action._choices.pop(ActionChoices.change_secret, None)
|
||||
|
||||
|
||||
class BaseACLSerializer(ActionAclSerializer, serializers.Serializer):
|
||||
|
||||
@@ -33,7 +33,10 @@ class CommandFilterACLSerializer(BaseSerializer, BulkOrgResourceModelSerializer)
|
||||
model = CommandFilterACL
|
||||
fields = BaseSerializer.Meta.fields + ['command_groups']
|
||||
action_choices_exclude = [
|
||||
ActionChoices.notice, ActionChoices.face_verify, ActionChoices.face_online
|
||||
ActionChoices.notice,
|
||||
ActionChoices.face_verify,
|
||||
ActionChoices.face_online,
|
||||
ActionChoices.change_secret
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ class ConnectMethodACLSerializer(BaseSerializer, BulkOrgResourceModelSerializer)
|
||||
if i not in ['assets', 'accounts']
|
||||
]
|
||||
action_choices_exclude = BaseSerializer.Meta.action_choices_exclude + [
|
||||
ActionChoices.review, ActionChoices.accept, ActionChoices.notice,
|
||||
ActionChoices.face_verify, ActionChoices.face_online
|
||||
ActionChoices.review,
|
||||
ActionChoices.accept,
|
||||
ActionChoices.notice,
|
||||
ActionChoices.face_verify,
|
||||
ActionChoices.face_online,
|
||||
ActionChoices.change_secret
|
||||
]
|
||||
|
||||
@@ -22,7 +22,8 @@ class LoginACLSerializer(BaseUserACLSerializer, BulkOrgResourceModelSerializer):
|
||||
ActionChoices.warning,
|
||||
ActionChoices.notify_and_warn,
|
||||
ActionChoices.face_online,
|
||||
ActionChoices.face_verify
|
||||
ActionChoices.face_verify,
|
||||
ActionChoices.change_secret
|
||||
]
|
||||
|
||||
def get_rules_serializer(self):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# coding: utf-8
|
||||
#
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
@@ -8,7 +10,7 @@ from common.utils.ip import is_ip_address, is_ip_network, is_ip_segment
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
__all__ = ['RuleSerializer', 'ip_group_child_validator', 'ip_group_help_text']
|
||||
__all__ = ['RuleSerializer', 'ip_group_child_validator', 'ip_group_help_text', 'address_validator']
|
||||
|
||||
|
||||
def ip_group_child_validator(ip_group_child):
|
||||
@@ -21,6 +23,19 @@ def ip_group_child_validator(ip_group_child):
|
||||
raise serializers.ValidationError(error)
|
||||
|
||||
|
||||
def address_validator(value):
|
||||
parsed = urlparse(value)
|
||||
is_basic_url = parsed.scheme in ('http', 'https') and parsed.netloc
|
||||
is_valid = value == '*' \
|
||||
or is_ip_address(value) \
|
||||
or is_ip_network(value) \
|
||||
or is_ip_segment(value) \
|
||||
or is_basic_url
|
||||
if not is_valid:
|
||||
error = _('address invalid: `{}`').format(value)
|
||||
raise serializers.ValidationError(error)
|
||||
|
||||
|
||||
ip_group_help_text = _(
|
||||
'With * indicating a match all. '
|
||||
'Such as: '
|
||||
|
||||
@@ -22,6 +22,7 @@ from common.tasks import send_mail_async
|
||||
from common.utils import get_logger, lazyproperty, is_openssh_format_key, ssh_pubkey_gen
|
||||
from ops.ansible import JMSInventory, DefaultCallback, SuperPlaybookRunner
|
||||
from ops.ansible.interface import interface
|
||||
from users.utils import activate_user_language
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -122,9 +123,7 @@ class BaseManager:
|
||||
self.execution.summary = self.summary
|
||||
self.execution.result = self.result
|
||||
self.execution.status = self.status
|
||||
|
||||
with safe_atomic_db_connection():
|
||||
self.execution.save()
|
||||
self.execution.save()
|
||||
|
||||
def print_summary(self):
|
||||
content = "\nSummery: \n"
|
||||
@@ -151,12 +150,13 @@ class BaseManager:
|
||||
if not recipients:
|
||||
return
|
||||
print(f"Send report to: {','.join([str(u) for u in recipients])}")
|
||||
|
||||
report = self.gen_report()
|
||||
report = transform(report, cssutils_logging_level="CRITICAL")
|
||||
subject = self.get_report_subject()
|
||||
emails = [r.email for r in recipients if r.email]
|
||||
send_mail_async(subject, report, emails, html_message=report)
|
||||
for user in recipients:
|
||||
with activate_user_language(user):
|
||||
report = self.gen_report()
|
||||
report = transform(report, cssutils_logging_level="CRITICAL")
|
||||
subject = self.get_report_subject()
|
||||
emails = [user.email]
|
||||
send_mail_async(subject, report, emails, html_message=report)
|
||||
|
||||
def gen_report(self):
|
||||
template_path = self.get_report_template()
|
||||
@@ -165,9 +165,10 @@ class BaseManager:
|
||||
return data
|
||||
|
||||
def post_run(self):
|
||||
self.update_execution()
|
||||
self.print_summary()
|
||||
self.send_report_if_need()
|
||||
with safe_atomic_db_connection():
|
||||
self.update_execution()
|
||||
self.print_summary()
|
||||
self.send_report_if_need()
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
self.pre_run()
|
||||
@@ -546,7 +547,8 @@ class BasePlaybookManager(PlaybookPrepareMixin, BaseManager):
|
||||
try:
|
||||
kwargs.update({"clean_workspace": False})
|
||||
cb = runner.run(**kwargs)
|
||||
self.on_runner_success(runner, cb)
|
||||
with safe_atomic_db_connection():
|
||||
self.on_runner_success(runner, cb)
|
||||
except Exception as e:
|
||||
self.on_runner_failed(runner, e, **info)
|
||||
finally:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
vars:
|
||||
ansible_shell_type: sh
|
||||
ansible_connection: local
|
||||
ansible_python_interpreter: /opt/py3/bin/python
|
||||
ansible_python_interpreter: "{{ local_python_interpreter }}"
|
||||
|
||||
tasks:
|
||||
- name: Test asset connection (pyfreerdp)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
- hosts: demo
|
||||
gather_facts: no
|
||||
vars:
|
||||
ansible_timeout: 30
|
||||
tasks:
|
||||
- name: Posix ping
|
||||
ansible.builtin.ping:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
- hosts: windows
|
||||
gather_facts: no
|
||||
vars:
|
||||
ansible_timeout: 30
|
||||
tasks:
|
||||
- name: Refresh connection
|
||||
ansible.builtin.meta: reset_connection
|
||||
|
||||
@@ -194,6 +194,12 @@ class Protocol(ChoicesMixin, models.TextChoices):
|
||||
'default': '>=2014',
|
||||
'label': _('Version'),
|
||||
'help_text': _('SQL Server version, Different versions have different connection drivers')
|
||||
},
|
||||
'encrypt': {
|
||||
'type': 'bool',
|
||||
'default': True,
|
||||
'label': _('Encrypt'),
|
||||
'help_text': _('Whether to use TLS encryption.')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -46,7 +46,7 @@ class DatabaseSerializer(AssetSerializer):
|
||||
elif self.context.get('request'):
|
||||
platform_id = self.context['request'].query_params.get('platform')
|
||||
|
||||
if not platform and platform_id:
|
||||
if not platform and platform_id and str(platform_id).isdigit():
|
||||
platform = Platform.objects.filter(id=platform_id).first()
|
||||
return platform
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from rbac.permissions import RBACPermission
|
||||
from terminal.models import default_storage
|
||||
from users.models import User
|
||||
from .backends import TYPE_ENGINE_MAPPING
|
||||
from .const import ActivityChoices
|
||||
from .const import ActivityChoices, ActionChoices
|
||||
from .filters import UserSessionFilterSet, OperateLogFilterSet
|
||||
from .models import (
|
||||
FTPLog, UserLoginLog, OperateLog, PasswordChangeLog,
|
||||
@@ -45,7 +45,7 @@ from .serializers import (
|
||||
FileSerializer, UserSessionSerializer, JobsAuditSerializer,
|
||||
ServiceAccessLogSerializer
|
||||
)
|
||||
from .utils import construct_userlogin_usernames
|
||||
from .utils import construct_userlogin_usernames, record_operate_log_and_activity_log
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -126,6 +126,11 @@ class FTPLogViewSet(OrgModelViewSet):
|
||||
response['Content-Type'] = 'application/octet-stream'
|
||||
filename = escape_uri_path(ftp_log.filename)
|
||||
response["Content-Disposition"] = "attachment; filename*=UTF-8''{}".format(filename)
|
||||
|
||||
record_operate_log_and_activity_log(
|
||||
[ftp_log.id], ActionChoices.download, '', self.model,
|
||||
resource_display=f'{ftp_log.asset}: {ftp_log.filename}',
|
||||
)
|
||||
return response
|
||||
|
||||
@action(methods=[POST], detail=True, permission_classes=[IsServiceAccount, ], serializer_class=FileSerializer)
|
||||
|
||||
@@ -35,6 +35,7 @@ class OperateLogStore(ES, metaclass=Singleton):
|
||||
}
|
||||
}
|
||||
exact_fields = {}
|
||||
fuzzy_fields = {}
|
||||
match_fields = {
|
||||
'id', 'user', 'action', 'resource_type',
|
||||
'resource', 'remote_addr', 'org_id'
|
||||
@@ -44,7 +45,7 @@ class OperateLogStore(ES, metaclass=Singleton):
|
||||
}
|
||||
if not config.get('INDEX'):
|
||||
config['INDEX'] = 'jumpserver_operate_log'
|
||||
super().__init__(config, properties, keyword_fields, exact_fields, match_fields)
|
||||
super().__init__(config, properties, keyword_fields, exact_fields, fuzzy_fields, match_fields)
|
||||
self.pre_use_check()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -29,7 +29,7 @@ class ActionChoices(TextChoices):
|
||||
download = "download", _("Download")
|
||||
connect = "connect", _("Connect")
|
||||
login = "login", _("Login")
|
||||
change_auth = "change_password", _("Change password")
|
||||
change_auth = "change_password", _("Change secret")
|
||||
|
||||
accept = 'accept', _('Accept')
|
||||
review = 'review', _('Review')
|
||||
|
||||
@@ -75,7 +75,7 @@ class Migration(migrations.Migration):
|
||||
("download", "Download"),
|
||||
("connect", "Connect"),
|
||||
("login", "Login"),
|
||||
("change_password", "Change password"),
|
||||
("change_password", "Change secret"),
|
||||
("accept", "Accept"),
|
||||
("review", "Review"),
|
||||
("notice", "Notifications"),
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('audits', '0005_rename_serviceaccesslog'),
|
||||
]
|
||||
@@ -18,7 +17,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='ftplog',
|
||||
name='asset',
|
||||
field=models.CharField(db_index=True, max_length=1024, verbose_name='Asset'),
|
||||
field=models.CharField(db_index=True, max_length=767, verbose_name='Asset'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ftplog',
|
||||
|
||||
17
apps/audits/migrations/0007_auto_20250610_1704.py
Normal file
17
apps/audits/migrations/0007_auto_20250610_1704.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.1.13 on 2025-06-10 09:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("audits", "0006_alter_ftplog_account_alter_ftplog_asset_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='ftplog',
|
||||
name='asset',
|
||||
field=models.CharField(db_index=True, max_length=768, verbose_name='Asset'),
|
||||
),
|
||||
]
|
||||
@@ -56,7 +56,7 @@ class FTPLog(OrgModelMixin):
|
||||
remote_addr = models.CharField(
|
||||
max_length=128, verbose_name=_("Remote addr"), blank=True, null=True
|
||||
)
|
||||
asset = models.CharField(max_length=1024, verbose_name=_("Asset"), db_index=True)
|
||||
asset = models.CharField(max_length=768, verbose_name=_("Asset"), db_index=True)
|
||||
account = models.CharField(max_length=128, verbose_name=_("Account"), db_index=True)
|
||||
operate = models.CharField(
|
||||
max_length=16, verbose_name=_("Operate"), choices=OperateChoices.choices
|
||||
@@ -73,6 +73,9 @@ class FTPLog(OrgModelMixin):
|
||||
models.Index(fields=['date_start', 'org_id'], name='idx_date_start_org'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return "{0.id} of {0.user} to {0.asset}".format(self)
|
||||
|
||||
@property
|
||||
def filepath(self):
|
||||
return os.path.join(self.upload_to, self.date_start.strftime('%Y-%m-%d'), str(self.id))
|
||||
|
||||
@@ -89,6 +89,8 @@ def create_activities(resource_ids, detail, detail_id, action, org_id):
|
||||
for activity in activities:
|
||||
create_activity(activity)
|
||||
|
||||
create_activity.finish()
|
||||
|
||||
|
||||
@signals.after_task_publish.connect
|
||||
def after_task_publish_for_activity_log(headers=None, body=None, **kwargs):
|
||||
|
||||
@@ -180,7 +180,7 @@ def on_django_start_set_operate_log_monitor_models(sender, **kwargs):
|
||||
'PlatformAutomation', 'PlatformProtocol', 'Protocol',
|
||||
'HistoricalAccount', 'GatheredUser', 'ApprovalRule',
|
||||
'BaseAutomation', 'CeleryTask', 'Command', 'JobLog',
|
||||
'ConnectionToken', 'SessionJoinRecord',
|
||||
'ConnectionToken', 'SessionJoinRecord', 'SessionSharing',
|
||||
'HistoricalJob', 'Status', 'TicketStep', 'Ticket',
|
||||
'UserAssetGrantedTreeNodeRelation', 'TicketAssignee',
|
||||
'SuperTicket', 'SuperConnectionToken', 'AdminConnectionToken', 'PermNode',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import copy
|
||||
from datetime import datetime
|
||||
from itertools import chain
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
@@ -7,7 +8,6 @@ from django.db import models
|
||||
from django.db.models import F, Value, CharField
|
||||
from django.db.models.functions import Concat
|
||||
from django.utils import translation
|
||||
from itertools import chain
|
||||
|
||||
from common.db.fields import RelatedManager
|
||||
from common.utils import validate_ip, get_ip_city, get_logger
|
||||
@@ -16,7 +16,6 @@ from .const import DEFAULT_CITY, ActivityChoices as LogChoice
|
||||
from .handler import create_or_update_operate_log
|
||||
from .models import ActivityLog
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -151,7 +150,7 @@ def record_operate_log_and_activity_log(ids, action, detail, model, **kwargs):
|
||||
|
||||
org_id = current_org.id
|
||||
with translation.override('en'):
|
||||
resource_type = model._meta.verbose_name
|
||||
resource_type = kwargs.pop('resource_type', None) or model._meta.verbose_name
|
||||
create_or_update_operate_log(action, resource_type, force=True, **kwargs)
|
||||
base_data = {'type': LogChoice.operate_log, 'detail': detail, 'org_id': org_id}
|
||||
activities = [ActivityLog(resource_id=r_id, **base_data) for r_id in ids]
|
||||
|
||||
@@ -37,6 +37,7 @@ class UserConfirmationViewSet(JMSGenericViewSet):
|
||||
backend_classes = ConfirmType.get_prop_backends(confirm_type)
|
||||
if not backend_classes:
|
||||
return
|
||||
|
||||
for backend_cls in backend_classes:
|
||||
backend = backend_cls(self.request.user, self.request)
|
||||
if not backend.check():
|
||||
@@ -69,6 +70,7 @@ class UserConfirmationViewSet(JMSGenericViewSet):
|
||||
ok, msg = backend.authenticate(secret_key, mfa_type)
|
||||
if ok:
|
||||
request.session['CONFIRM_LEVEL'] = ConfirmType.values.index(confirm_type) + 1
|
||||
request.session['CONFIRM_TYPE'] = confirm_type
|
||||
request.session['CONFIRM_TIME'] = int(time.time())
|
||||
return Response('ok')
|
||||
return Response({'error': msg}, status=400)
|
||||
|
||||
@@ -369,12 +369,13 @@ class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixi
|
||||
'terminal_theme_name': 'Default',
|
||||
}
|
||||
preferences_query = Preference.objects.filter(
|
||||
user=user, category='koko', name__in=default_name_opts.keys()
|
||||
user=user, category='luna', name__in=default_name_opts.keys()
|
||||
).values_list('name', 'value')
|
||||
preferences = dict(preferences_query)
|
||||
for name in default_name_opts.keys():
|
||||
value = preferences.get(name, default_name_opts[name])
|
||||
connect_options[name] = value
|
||||
connect_options['lang'] = getattr(user, 'lang', settings.LANGUAGE_CODE)
|
||||
data['connect_options'] = connect_options
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
from django.core.cache import cache
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.generics import CreateAPIView, RetrieveAPIView
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.exceptions import NotFound
|
||||
|
||||
from common.permissions import IsServiceAccount
|
||||
from common.utils import get_logger, get_object_or_none
|
||||
from orgs.utils import tmp_to_root_org
|
||||
from terminal.api.session.task import create_sessions_tasks
|
||||
from users.models import User
|
||||
|
||||
from .. import serializers
|
||||
from ..mixins import AuthMixin
|
||||
from ..const import FACE_CONTEXT_CACHE_KEY_PREFIX, FACE_SESSION_KEY, FACE_CONTEXT_CACHE_TTL, FaceMonitorActionChoices
|
||||
from ..mixins import AuthMixin
|
||||
from ..models import ConnectionToken
|
||||
from ..serializers.face import FaceMonitorCallbackSerializer, FaceMonitorContextSerializer
|
||||
|
||||
@@ -93,7 +92,7 @@ class FaceCallbackApi(AuthMixin, CreateAPIView):
|
||||
connection_token_id = context.get('connection_token_id')
|
||||
token = ConnectionToken.objects.filter(id=connection_token_id).first()
|
||||
token.is_active = True
|
||||
token.save()
|
||||
token.save(update_fields=['is_active'])
|
||||
else:
|
||||
context.update({
|
||||
'success': False,
|
||||
|
||||
@@ -14,7 +14,6 @@ from rest_framework.response import Response
|
||||
from authentication.errors import ACLError
|
||||
from common.api import JMSGenericViewSet
|
||||
from common.const.http import POST, GET
|
||||
from common.permissions import OnlySuperUser
|
||||
from common.serializers import EmptySerializer
|
||||
from common.utils import reverse, safe_next_url
|
||||
from common.utils.timezone import utc_now
|
||||
@@ -38,8 +37,11 @@ class SSOViewSet(AuthMixin, JMSGenericViewSet):
|
||||
'login_url': SSOTokenSerializer,
|
||||
'login': EmptySerializer
|
||||
}
|
||||
rbac_perms = {
|
||||
'login_url': 'authentication.add_ssotoken',
|
||||
}
|
||||
|
||||
@action(methods=[POST], detail=False, permission_classes=[OnlySuperUser], url_path='login-url')
|
||||
@action(methods=[POST], detail=False, url_path='login-url')
|
||||
def login_url(self, request, *args, **kwargs):
|
||||
if not settings.AUTH_SSO:
|
||||
raise SSOAuthClosed()
|
||||
@@ -103,11 +105,9 @@ class SSOViewSet(AuthMixin, JMSGenericViewSet):
|
||||
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:
|
||||
|
||||
@@ -224,7 +224,6 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
||||
user_auth_failed.send(
|
||||
sender=self.__class__, request=request, username=user.username,
|
||||
reason="User is invalid", backend=settings.AUTH_BACKEND_OIDC_CODE
|
||||
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -10,16 +10,15 @@ import datetime as dt
|
||||
from calendar import timegm
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.encoding import force_bytes, smart_bytes
|
||||
from jwkest import JWKESTException
|
||||
from jwkest.jwk import KEYS
|
||||
from jwkest.jws import JWS
|
||||
from django.conf import settings
|
||||
|
||||
from common.utils import get_logger
|
||||
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
@@ -99,7 +98,8 @@ def _validate_claims(id_token, nonce=None, validate_nonce=True):
|
||||
raise SuspiciousOperation('Incorrect id_token: nbf')
|
||||
|
||||
# Verifies that the token was issued in the allowed timeframe.
|
||||
if utc_timestamp > id_token['iat'] + settings.AUTH_OPENID_ID_TOKEN_MAX_AGE:
|
||||
max_age = settings.AUTH_OPENID_ID_TOKEN_MAX_AGE
|
||||
if utc_timestamp > id_token['iat'] + max_age:
|
||||
logger.debug(log_prompt.format('Incorrect id_token: iat'))
|
||||
raise SuspiciousOperation('Incorrect id_token: iat')
|
||||
|
||||
|
||||
@@ -171,9 +171,10 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
|
||||
logger.debug(log_prompt.format('Process authenticate'))
|
||||
try:
|
||||
user = auth.authenticate(nonce=nonce, request=request, code_verifier=code_verifier)
|
||||
except IntegrityError:
|
||||
except IntegrityError as e:
|
||||
title = _("OpenID Error")
|
||||
msg = _('Please check if a user with the same username or email already exists')
|
||||
logger.error(e, exc_info=True)
|
||||
response = self.get_failed_response('/', title, msg)
|
||||
return response
|
||||
if user:
|
||||
|
||||
@@ -74,6 +74,7 @@ class PasskeyViewSet(AuthMixin, FlashMessageMixin, JMSModelViewSet):
|
||||
if confirm_mfa:
|
||||
request.session['CONFIRM_LEVEL'] = ConfirmType.values.index('mfa') + 1
|
||||
request.session['CONFIRM_TIME'] = int(time.time())
|
||||
request.session['CONFIRM_TYPE'] = ConfirmType.MFA
|
||||
request.session['passkey_confirm_mfa'] = ''
|
||||
return Response('ok')
|
||||
|
||||
|
||||
@@ -278,9 +278,10 @@ class Saml2AuthCallbackView(View, PrepareRequestMixin, FlashMessageMixin):
|
||||
saml_user_data = self.get_attributes(saml_instance)
|
||||
try:
|
||||
user = auth.authenticate(request=request, saml_user_data=saml_user_data)
|
||||
except IntegrityError:
|
||||
except IntegrityError as e:
|
||||
title = _("SAML2 Error")
|
||||
msg = _('Please check if a user with the same username or email already exists')
|
||||
logger.error(e, exc_info=True)
|
||||
response = self.get_failed_response('/', title, msg)
|
||||
return response
|
||||
if user and user.is_valid:
|
||||
|
||||
@@ -32,7 +32,7 @@ class MFAType(TextChoices):
|
||||
OTP = 'otp', _('OTP')
|
||||
SMS = 'sms', _('SMS')
|
||||
Email = 'email', _('Email')
|
||||
Face = 'face', _('Face Recognition')
|
||||
Face = 'face', _('Face recognition')
|
||||
Radius = 'otp_radius', _('Radius')
|
||||
Passkey = 'passkey', _('Passkey')
|
||||
Custom = 'mfa_custom', _('Custom')
|
||||
|
||||
@@ -9,7 +9,7 @@ from ..const import MFAType
|
||||
class MFAFace(BaseMFA, AuthFaceMixin):
|
||||
name = MFAType.Face.value
|
||||
display_name = MFAType.Face.name
|
||||
placeholder = 'Face Recognition'
|
||||
placeholder = 'Face recognition'
|
||||
skip_cache_check = True
|
||||
has_code = False
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class MFAMiddleware:
|
||||
|
||||
# 这个是 mfa 登录页需要的请求, 也得放出来, 用户其实已经在 CAS/OIDC 中完成登录了
|
||||
white_urls = [
|
||||
'login/mfa', 'mfa/select', 'face/context','jsi18n/', '/static/',
|
||||
'login/mfa', 'mfa/select', 'face/context', 'jsi18n/', '/static/',
|
||||
'/profile/otp', '/logout/',
|
||||
]
|
||||
for url in white_urls:
|
||||
@@ -77,6 +77,7 @@ class ThirdPartyLoginMiddleware(mixins.AuthMixin):
|
||||
ip = get_request_ip(request)
|
||||
try:
|
||||
self.request = request
|
||||
self.check_is_block()
|
||||
self._check_third_party_login_acl()
|
||||
self._check_login_acl(request.user, ip)
|
||||
except Exception as e:
|
||||
@@ -120,7 +121,10 @@ class SessionCookieMiddleware(MiddlewareMixin):
|
||||
USER_LOGIN_ENCRYPTION_KEY_PAIR = 'user_login_encryption_key_pair'
|
||||
|
||||
def set_cookie_public_key(self, request, response):
|
||||
if request.path.startswith('/api'):
|
||||
whitelist = [
|
||||
'/api/v1/authentication/sso/login/',
|
||||
]
|
||||
if request.path.startswith('/api') and request.path not in whitelist:
|
||||
return
|
||||
|
||||
session_public_key_name = settings.SESSION_RSA_PUBLIC_KEY_NAME
|
||||
|
||||
@@ -20,6 +20,7 @@ from django.utils.translation import gettext as _
|
||||
from rest_framework.request import Request
|
||||
|
||||
from acls.models import LoginACL
|
||||
from apps.jumpserver.settings.auth import AUTHENTICATION_BACKENDS_THIRD_PARTY
|
||||
from common.utils import get_request_ip_or_data, get_request_ip, get_logger, bulk_get, FlashMessageUtil
|
||||
from users.models import User
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil
|
||||
@@ -227,6 +228,10 @@ class MFAMixin:
|
||||
self._do_check_user_mfa(code, mfa_type, user=user)
|
||||
|
||||
def check_user_mfa_if_need(self, user):
|
||||
# 扫码登录的认证方式会执行该函数检查 mfa,跳转登录认证方式则通过ThirdPartyLoginMiddleware中间件检验 mfa
|
||||
if not settings.SECURITY_MFA_AUTH_ENABLED_FOR_THIRD_PARTY and \
|
||||
self.request.session.get('auth_backend') in AUTHENTICATION_BACKENDS_THIRD_PARTY:
|
||||
return
|
||||
if self.request.session.get('auth_mfa') and \
|
||||
self.request.session.get('auth_mfa_username') == user.username:
|
||||
return
|
||||
|
||||
@@ -14,23 +14,29 @@ from orgs.utils import tmp_to_root_org
|
||||
class UserConfirmation(permissions.BasePermission):
|
||||
ttl = 60 * 5
|
||||
min_level = 1
|
||||
confirm_type = 'relogin'
|
||||
min_type = 'relogin'
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not settings.SECURITY_VIEW_AUTH_NEED_MFA:
|
||||
return True
|
||||
|
||||
confirm_level = request.session.get('CONFIRM_LEVEL')
|
||||
confirm_type = request.session.get('CONFIRM_TYPE')
|
||||
confirm_time = request.session.get('CONFIRM_TIME')
|
||||
ttl = self.get_ttl()
|
||||
if not confirm_level or not confirm_time or \
|
||||
confirm_level < self.min_level or \
|
||||
confirm_time < time.time() - ttl:
|
||||
raise UserConfirmRequired(code=self.confirm_type)
|
||||
|
||||
ttl = self.get_ttl(confirm_type)
|
||||
now = int(time.time())
|
||||
|
||||
if not confirm_level or not confirm_time:
|
||||
raise UserConfirmRequired(code=self.min_type)
|
||||
|
||||
if confirm_level < self.min_level or \
|
||||
confirm_time < now - ttl:
|
||||
raise UserConfirmRequired(code=self.min_type)
|
||||
return True
|
||||
|
||||
def get_ttl(self):
|
||||
if self.confirm_type == ConfirmType.MFA:
|
||||
def get_ttl(self, confirm_type):
|
||||
if confirm_type == ConfirmType.MFA:
|
||||
ttl = settings.SECURITY_MFA_VERIFY_TTL
|
||||
else:
|
||||
ttl = self.ttl
|
||||
@@ -40,7 +46,7 @@ class UserConfirmation(permissions.BasePermission):
|
||||
def require(cls, confirm_type=ConfirmType.RELOGIN, ttl=60 * 5):
|
||||
min_level = ConfirmType.values.index(confirm_type) + 1
|
||||
name = 'UserConfirmationLevel{}TTL{}'.format(min_level, ttl)
|
||||
return type(name, (cls,), {'min_level': min_level, 'ttl': ttl, 'confirm_type': confirm_type})
|
||||
return type(name, (cls,), {'min_level': min_level, 'ttl': ttl, 'min_type': confirm_type})
|
||||
|
||||
|
||||
class IsValidUserOrConnectionToken(IsValidUser):
|
||||
|
||||
@@ -75,6 +75,7 @@ class SuperConnectionTokenSerializer(ConnectionTokenSerializer):
|
||||
def get_user(self, attrs):
|
||||
return attrs.get('user')
|
||||
|
||||
|
||||
class AdminConnectionTokenSerializer(ConnectionTokenSerializer):
|
||||
class Meta(ConnectionTokenSerializer.Meta):
|
||||
model = AdminConnectionToken
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentication.models import ConnectionToken, TempToken
|
||||
from common.const.crontab import CRONTAB_AT_AM_TWO
|
||||
from ops.celery.decorator import register_as_period_task
|
||||
from orgs.utils import tmp_to_root_org
|
||||
|
||||
|
||||
@shared_task(
|
||||
@@ -18,3 +24,26 @@ from ops.celery.decorator import register_as_period_task
|
||||
@register_as_period_task(interval=3600 * 24)
|
||||
def clean_django_sessions():
|
||||
Session.objects.filter(expire_date__lt=timezone.now()).delete()
|
||||
|
||||
|
||||
@shared_task(
|
||||
verbose_name=_('Clean expired temporary, connection tokens'),
|
||||
description=_(
|
||||
"When connecting to assets or generating temporary passwords, the system creates corresponding connection "
|
||||
"tokens or temporary credential records. To maintain security and manage storage, the system automatically "
|
||||
"deletes expired tokens every day at 2:00 AM based on the retention settings configured under System settings "
|
||||
"> Security > User password > Token Retention Period"
|
||||
)
|
||||
)
|
||||
@register_as_period_task(crontab=CRONTAB_AT_AM_TWO)
|
||||
def clean_expire_token():
|
||||
logging.info('Cleaning expired temporary and connection tokens...')
|
||||
with tmp_to_root_org():
|
||||
now = timezone.now()
|
||||
days = settings.SECURITY_EXPIRED_TOKEN_RECORD_KEEP_DAYS
|
||||
expired_time = now - datetime.timedelta(days=days)
|
||||
count = ConnectionToken.objects.filter(date_expired__lt=expired_time).delete()
|
||||
logging.info('Deleted %d expired connection tokens.', count[0])
|
||||
count = TempToken.objects.filter(date_expired__lt=expired_time).delete()
|
||||
logging.info('Deleted %d temporary tokens.', count[0])
|
||||
logging.info('Cleaned expired temporary and connection tokens.')
|
||||
|
||||
@@ -436,7 +436,7 @@
|
||||
|
||||
</body>
|
||||
{% include '_foot_js.html' %}
|
||||
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script>
|
||||
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.3.3.2.min.js"></script>
|
||||
<script type="text/javascript" src="/static/js/plugins/cryptojs/crypto-js.min.js"></script>
|
||||
<script type="text/javascript" src="/static/js/plugins/buffer/buffer.min.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -91,27 +91,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
const publicKeyCredentialToJSON = (pubKeyCred) => {
|
||||
if (pubKeyCred instanceof Array) {
|
||||
const arr = []
|
||||
for (const i of pubKeyCred) {
|
||||
arr.push(publicKeyCredentialToJSON(i))
|
||||
}
|
||||
return arr
|
||||
const publicKeyCredentialToJSON = pubKeyCred => {
|
||||
if (pubKeyCred instanceof Array) {
|
||||
const arr = []
|
||||
for (const i of pubKeyCred) {
|
||||
arr.push(publicKeyCredentialToJSON(i))
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
if (pubKeyCred instanceof ArrayBuffer || pubKeyCred instanceof Uint8Array) {
|
||||
return encode(pubKeyCred)
|
||||
}
|
||||
|
||||
if (pubKeyCred instanceof Object) {
|
||||
const obj = {}
|
||||
|
||||
for (const key in pubKeyCred) {
|
||||
obj[key] = publicKeyCredentialToJSON(pubKeyCred[key])
|
||||
}
|
||||
|
||||
if (pubKeyCred instanceof ArrayBuffer) {
|
||||
return encode(pubKeyCred)
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
if (pubKeyCred instanceof Object) {
|
||||
const obj = {}
|
||||
for (const key in pubKeyCred) {
|
||||
obj[key] = publicKeyCredentialToJSON(pubKeyCred[key])
|
||||
}
|
||||
return obj
|
||||
}
|
||||
return pubKeyCred
|
||||
return pubKeyCred
|
||||
}
|
||||
|
||||
function GetAssertReq(getAssert) {
|
||||
|
||||
@@ -150,6 +150,7 @@ class BaseBindCallbackView(FlashMessageMixin, IMClientMixin, View):
|
||||
user.save()
|
||||
except IntegrityError as e:
|
||||
msg = _('The %s is already bound to another user') % self.auth_type_label
|
||||
logger.error(e, exc_info=True)
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
|
||||
@@ -144,6 +144,7 @@ class DingTalkQRBindCallbackView(DingTalkQRMixin, View):
|
||||
user.save()
|
||||
except IntegrityError as e:
|
||||
msg = _('The DingTalk is already bound to another user')
|
||||
logger.error(e, exc_info=True)
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
|
||||
@@ -294,6 +294,19 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView):
|
||||
)
|
||||
return url
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
from django.utils import timezone
|
||||
response = super().get(request, *args, **kwargs)
|
||||
try:
|
||||
response.set_cookie(
|
||||
settings.LANGUAGE_COOKIE_NAME,
|
||||
request.user.lang,
|
||||
expires=timezone.now() + timezone.timedelta(days=365)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
|
||||
class UserLoginWaitConfirmView(TemplateView):
|
||||
template_name = 'authentication/login_wait_confirm.html'
|
||||
|
||||
@@ -8,6 +8,8 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.const.http import POST, PUT
|
||||
from orgs.models import Organization
|
||||
from orgs.utils import current_org
|
||||
|
||||
__all__ = ['SuggestionMixin', 'RenderToJsonMixin']
|
||||
|
||||
@@ -23,7 +25,16 @@ class SuggestionMixin:
|
||||
|
||||
@action(methods=['get'], detail=False, url_path='suggestions')
|
||||
def match(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
queryset = self.get_queryset()
|
||||
org_id = str(current_org.id)
|
||||
if (
|
||||
not request.user.is_superuser and
|
||||
org_id != Organization.ROOT_ID and
|
||||
not request.user.orgs.filter(id=org_id).exists()
|
||||
):
|
||||
queryset = queryset.none()
|
||||
|
||||
queryset = self.filter_queryset(queryset)
|
||||
queryset = queryset[:self.suggestion_limit]
|
||||
page = self.paginate_queryset(queryset)
|
||||
|
||||
|
||||
@@ -131,17 +131,18 @@ class LicenseEditionChoices(models.TextChoices):
|
||||
if choice == key:
|
||||
return choice
|
||||
return LicenseEditionChoices.COMMUNITY
|
||||
|
||||
@staticmethod
|
||||
def parse_license_edition(info):
|
||||
count = info.get('license', {}).get('count', 0)
|
||||
|
||||
if 50 >= count > 0:
|
||||
if 0 < count <= 50:
|
||||
return LicenseEditionChoices.BASIC
|
||||
elif count <= 500:
|
||||
return LicenseEditionChoices.STANDARD
|
||||
elif count < 5000:
|
||||
elif count <= 5000:
|
||||
return LicenseEditionChoices.PROFESSIONAL
|
||||
elif count >= 5000:
|
||||
elif count > 5000:
|
||||
return LicenseEditionChoices.ULTIMATE
|
||||
else:
|
||||
return LicenseEditionChoices.COMMUNITY
|
||||
|
||||
@@ -50,13 +50,14 @@ def get_objects(model, pks):
|
||||
|
||||
|
||||
# 复制 django.db.close_old_connections, 因为它没有导出,ide 提示有问题
|
||||
def close_old_connections():
|
||||
for conn in connections.all():
|
||||
def close_old_connections(**kwargs):
|
||||
for conn in connections.all(initialized_only=True):
|
||||
conn.close_if_unusable_or_obsolete()
|
||||
|
||||
|
||||
# 这个要是在 Django 请求周期外使用的,不能影响 Django 的事务管理, 在 api 中使用会影响 api 事务
|
||||
@contextmanager
|
||||
def safe_db_connection(auto_close=True):
|
||||
def safe_db_connection():
|
||||
close_old_connections()
|
||||
yield
|
||||
close_old_connections()
|
||||
@@ -64,19 +65,25 @@ def safe_db_connection(auto_close=True):
|
||||
|
||||
@contextmanager
|
||||
def safe_atomic_db_connection(auto_close=False):
|
||||
in_atomic_block = connection.in_atomic_block # 当前是否处于事务中
|
||||
autocommit = transaction.get_autocommit() # 是否启用了自动提交
|
||||
created = False
|
||||
"""
|
||||
通用数据库连接管理器(线程安全、事务感知):
|
||||
- 在连接不可用时主动重建连接
|
||||
- 在非事务环境下自动关闭连接(可选)
|
||||
- 不影响 Django 请求/事务周期
|
||||
"""
|
||||
in_atomic = connection.in_atomic_block # 当前是否在事务中
|
||||
autocommit = transaction.get_autocommit()
|
||||
recreated = False
|
||||
|
||||
try:
|
||||
if not connection.is_usable():
|
||||
connection.close()
|
||||
connection.connect()
|
||||
created = True
|
||||
recreated = True
|
||||
yield
|
||||
finally:
|
||||
# 如果不是事务中(API 请求中可能需要提交事务),则关闭连接
|
||||
if auto_close or (created and not in_atomic_block and autocommit):
|
||||
# 只在非事务、autocommit 模式下,才考虑主动清理连接
|
||||
if auto_close or (recreated and not in_atomic and autocommit):
|
||||
close_old_connections()
|
||||
|
||||
|
||||
|
||||
@@ -302,16 +302,8 @@ def bulk_handle(handler, batch_size=50, timeout=0.5):
|
||||
|
||||
cache = [] # 缓存实例的列表
|
||||
lock = threading.Lock() # 用于线程安全
|
||||
timer = [None] # 定时器对象,列表存储以便重置
|
||||
org_id = None
|
||||
|
||||
def reset_timer():
|
||||
"""重置定时器"""
|
||||
if timer[0] is not None:
|
||||
timer[0].cancel()
|
||||
timer[0] = threading.Timer(timeout, handle_remaining)
|
||||
timer[0].start()
|
||||
|
||||
def handle_it():
|
||||
from orgs.utils import tmp_to_org
|
||||
with lock:
|
||||
@@ -351,17 +343,13 @@ def bulk_handle(handler, batch_size=50, timeout=0.5):
|
||||
if len(cache) >= batch_size:
|
||||
handle_it()
|
||||
|
||||
reset_timer()
|
||||
return instance
|
||||
|
||||
# 提交剩余实例的方法
|
||||
def handle_remaining():
|
||||
if not cache:
|
||||
return
|
||||
print("Timer expired. Saving remaining instances.")
|
||||
from orgs.utils import tmp_to_org
|
||||
with tmp_to_org(org_id):
|
||||
handle_it()
|
||||
handle_it()
|
||||
|
||||
wrapper.finish = handle_remaining
|
||||
return wrapper
|
||||
|
||||
@@ -25,3 +25,4 @@ BASE_DIR = os.path.dirname(settings.BASE_DIR)
|
||||
LOG_DIR = os.path.join(BASE_DIR, 'data', 'logs')
|
||||
APPS_DIR = os.path.join(BASE_DIR, 'apps')
|
||||
TMP_DIR = os.path.join(BASE_DIR, 'tmp')
|
||||
CELERY_WORKER_COUNT = CONFIG.CELERY_WORKER_COUNT or 10
|
||||
|
||||
@@ -4,10 +4,10 @@ from ..hands import *
|
||||
|
||||
class CeleryBaseService(BaseService):
|
||||
|
||||
def __init__(self, queue, num=10, **kwargs):
|
||||
def __init__(self, queue, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.queue = queue
|
||||
self.num = num
|
||||
self.num = CELERY_WORKER_COUNT
|
||||
|
||||
@property
|
||||
def cmd(self):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
@@ -30,6 +31,8 @@ class WithBootstrapToken(permissions.BasePermission):
|
||||
def check_can_register(self):
|
||||
enabled = settings.SECURITY_SERVICE_ACCOUNT_REGISTRATION
|
||||
if enabled == 'auto':
|
||||
if cache.get(f'APPLET_HOST_DELOYING'):
|
||||
return True
|
||||
return time.time() - settings.JUMPSERVER_UPTIME < 300
|
||||
elif enabled:
|
||||
return True
|
||||
|
||||
@@ -123,7 +123,7 @@ def get_es_client_version(**kwargs):
|
||||
|
||||
class ES(object):
|
||||
|
||||
def __init__(self, config, properties, keyword_fields, exact_fields=None, match_fields=None):
|
||||
def __init__(self, config, properties, keyword_fields, exact_fields=None, fuzzy_fields=None, match_fields=None, **kwargs):
|
||||
self.version = 7
|
||||
self.config = config
|
||||
hosts = self.config.get('HOSTS')
|
||||
@@ -140,7 +140,7 @@ class ES(object):
|
||||
self.index = None
|
||||
self.query_index = None
|
||||
self.properties = properties
|
||||
self.exact_fields, self.match_fields, self.keyword_fields = set(), set(), set()
|
||||
self.exact_fields, self.match_fields, self.keyword_fields, self.fuzzy_fields = set(), set(), set(), set()
|
||||
|
||||
if isinstance(keyword_fields, Iterable):
|
||||
self.keyword_fields.update(keyword_fields)
|
||||
@@ -148,6 +148,8 @@ class ES(object):
|
||||
self.exact_fields.update(exact_fields)
|
||||
if isinstance(match_fields, Iterable):
|
||||
self.match_fields.update(match_fields)
|
||||
if isinstance(fuzzy_fields, Iterable):
|
||||
self.fuzzy_fields.update(fuzzy_fields)
|
||||
|
||||
self.init_index()
|
||||
self.doc_type = self.config.get("DOC_TYPE") or '_doc'
|
||||
@@ -314,6 +316,17 @@ class ES(object):
|
||||
query: {k: v}
|
||||
})
|
||||
return _filter
|
||||
|
||||
@staticmethod
|
||||
def handle_fuzzy_fields(exact):
|
||||
_filter = []
|
||||
for k, v in exact.items():
|
||||
_filter.append({ 'wildcard': { k: f'*{v}*' } })
|
||||
return _filter
|
||||
|
||||
@staticmethod
|
||||
def is_keyword(props: dict, field: str) -> bool:
|
||||
return props.get(field, {}).get("type", "keyword") == "keyword"
|
||||
|
||||
def get_query_body(self, **kwargs):
|
||||
new_kwargs = {}
|
||||
@@ -331,18 +344,37 @@ class ES(object):
|
||||
keyword_fields = self.keyword_fields
|
||||
exact_fields = self.exact_fields
|
||||
match_fields = self.match_fields
|
||||
fuzzy_fields = self.fuzzy_fields
|
||||
|
||||
match = {}
|
||||
search = []
|
||||
exact = {}
|
||||
fuzzy = {}
|
||||
index = {}
|
||||
|
||||
if index_in_field in kwargs:
|
||||
index['values'] = kwargs[index_in_field]
|
||||
|
||||
mapping = self.es.indices.get_mapping(index=self.query_index)
|
||||
props = (
|
||||
mapping
|
||||
.get(self.query_index, {})
|
||||
.get('mappings', {})
|
||||
.get('properties', {})
|
||||
)
|
||||
|
||||
common_keyword_able = exact_fields | keyword_fields
|
||||
|
||||
for k, v in kwargs.items():
|
||||
if k in exact_fields.union(keyword_fields):
|
||||
exact['{}.keyword'.format(k)] = v
|
||||
if k in ("org_id", "session") and self.is_keyword(props, k):
|
||||
exact[k] = v
|
||||
|
||||
elif k in common_keyword_able:
|
||||
exact[f"{k}.keyword"] = v
|
||||
|
||||
elif k in fuzzy_fields:
|
||||
fuzzy[f"{k}.keyword"] = v
|
||||
|
||||
elif k in match_fields:
|
||||
match[k] = v
|
||||
|
||||
@@ -384,9 +416,10 @@ class ES(object):
|
||||
'should': should + [
|
||||
{'match': {k: v}} for k, v in match.items()
|
||||
] + [
|
||||
{'match': item} for item in search
|
||||
],
|
||||
{'match': item} for item in search
|
||||
],
|
||||
'filter': self.handle_exact_fields(exact) +
|
||||
self.handle_fuzzy_fields(fuzzy) +
|
||||
[
|
||||
{
|
||||
'range': {
|
||||
@@ -442,7 +475,7 @@ class QuerySet(DJQuerySet):
|
||||
names, multi_args, multi_kwargs = zip(*filter_calls)
|
||||
|
||||
# input 输入
|
||||
multi_args = tuple(reduce(lambda x, y: x + y, (sub for sub in multi_args if sub),()))
|
||||
multi_args = tuple(reduce(lambda x, y: x + y, (sub for sub in multi_args if sub), ()))
|
||||
args = self._grouped_search_args(multi_args)
|
||||
striped_args = [{k.replace('__icontains', ''): v} for k, values in args.items() for v in values]
|
||||
|
||||
@@ -548,4 +581,4 @@ class QuerySet(DJQuerySet):
|
||||
return iter(self.__execute())
|
||||
|
||||
def __len__(self):
|
||||
return self.count()
|
||||
return self.count()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
cipher_alg_id = {
|
||||
"sm4_ebc": 0x00000401,
|
||||
"sm4_cbc": 0x00000402,
|
||||
"sm4_mac": 0x00000405,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class CMPPSubmitRequestInstance(CMPPBaseRequestInstance):
|
||||
pk_number = struct.pack('!B', 1)
|
||||
registered_delivery = struct.pack('!B', 0)
|
||||
msg_level = struct.pack('!B', 0)
|
||||
service_id = ((10 - len(service_id)) * '\x00' + service_id).encode('utf-8')
|
||||
service_id = service_id.ljust(10, '\x00').encode('utf-8')
|
||||
fee_user_type = struct.pack('!B', 2)
|
||||
fee_terminal_id = ('0' * 21).encode('utf-8')
|
||||
tp_pid = struct.pack('!B', 0)
|
||||
@@ -85,7 +85,7 @@ class CMPPSubmitRequestInstance(CMPPBaseRequestInstance):
|
||||
fee_code = '000000'.encode('utf-8')
|
||||
valid_time = ('\x00' * 17).encode('utf-8')
|
||||
at_time = ('\x00' * 17).encode('utf-8')
|
||||
src_id = ((21 - len(src_id)) * '\x00' + src_id).encode('utf-8')
|
||||
src_id = src_id.ljust(21, '\x00').encode('utf-8')
|
||||
reserve = b'\x00' * 8
|
||||
_msg_length = struct.pack('!B', len(msg_content) * 2)
|
||||
_msg_src = msg_src.encode('utf-8')
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.core.mail import send_mail, EmailMultiAlternatives, get_connection
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.storage import jms_storage
|
||||
from users.models import User
|
||||
from .utils import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -48,6 +49,8 @@ def send_mail_async(*args, **kwargs):
|
||||
Example:
|
||||
send_mail_sync.delay(subject, message, recipient_list, fail_silently=False, html_message=None)
|
||||
"""
|
||||
from users.utils import activate_user_language
|
||||
|
||||
if len(args) == 3:
|
||||
args = list(args)
|
||||
args[0] = (settings.EMAIL_SUBJECT_PREFIX or '') + args[0]
|
||||
@@ -55,8 +58,18 @@ def send_mail_async(*args, **kwargs):
|
||||
args.insert(2, from_email)
|
||||
|
||||
args = tuple(args)
|
||||
|
||||
subject = args[0] if len(args) > 0 else kwargs.get('subject')
|
||||
recipient_list = args[3] if len(args) > 3 else kwargs.get('recipient_list')
|
||||
logger.info(
|
||||
"send_mail_async called with subject=%r, recipients=%r", subject, recipient_list
|
||||
)
|
||||
|
||||
try:
|
||||
return send_mail(connection=get_email_connection(), *args, **kwargs)
|
||||
users = User.objects.filter(email__in=recipient_list).all()
|
||||
for user in users:
|
||||
with activate_user_language(user):
|
||||
send_mail(connection=get_email_connection(), *args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error("Sending mail error: {}".format(e))
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ def get_ip_city_by_geoip(ip):
|
||||
global reader
|
||||
if reader is None:
|
||||
path = os.path.join(os.path.dirname(__file__), 'GeoLite2-City.mmdb')
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"IP Database not found, please run `./requirements/static_files.sh`")
|
||||
reader = geoip2.database.Reader(path)
|
||||
|
||||
try:
|
||||
|
||||
@@ -12,6 +12,9 @@ def get_ip_city_by_ipip(ip):
|
||||
global ipip_db
|
||||
if ipip_db is None:
|
||||
ipip_db_path = os.path.join(os.path.dirname(__file__), 'ipipfree.ipdb')
|
||||
if not os.path.exists(ipip_db_path):
|
||||
raise FileNotFoundError(
|
||||
f"IP database not found, please run `bash ./requirements/static_files.sh`")
|
||||
ipip_db = ipdb.City(ipip_db_path)
|
||||
try:
|
||||
info = ipip_db.find_info(ip, 'CN')
|
||||
|
||||
@@ -6,7 +6,7 @@ import socket
|
||||
import string
|
||||
import struct
|
||||
|
||||
string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~'
|
||||
string_punctuation = '!#$%&()*+,-.:;<=?@[]_~'
|
||||
|
||||
|
||||
def random_datetime(date_start, date_end):
|
||||
@@ -48,7 +48,6 @@ def random_string(
|
||||
|
||||
char_list = []
|
||||
if lower:
|
||||
|
||||
lower_chars = remove_exclude_char(string.ascii_lowercase, exclude_chars)
|
||||
if not lower_chars:
|
||||
raise ValueError('After excluding characters, no lowercase letters are available.')
|
||||
@@ -78,7 +77,7 @@ def random_string(
|
||||
if not special_chars:
|
||||
raise ValueError('After excluding characters, no special characters are available.')
|
||||
symbol_num = length // 16 + 1
|
||||
seq = random_replace_char(seq, symbols, symbol_num)
|
||||
seq = random_replace_char(seq, special_chars, symbol_num)
|
||||
secret_chars += seq
|
||||
|
||||
secrets.SystemRandom().shuffle(secret_chars)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"ActionPerm": "Actions",
|
||||
"ActionPerm": "Action Permission",
|
||||
"AlreadyExistsPleaseRename": "File already exists, please rename it",
|
||||
"AvailableShortcutKey": "Available Shortcut Key",
|
||||
"Back": "Back",
|
||||
"Cancel": "Cancel",
|
||||
"CancelFileUpload": "Cancel file upload",
|
||||
"Clone Connect": "Clone Connect",
|
||||
@@ -11,7 +14,8 @@
|
||||
"Connect": "Connect",
|
||||
"CopyLink": "Copy Link Address and Code",
|
||||
"CopyShareURLSuccess": "Copy Share URL Success",
|
||||
"CreateLink": "Create Share Link",
|
||||
"CreateFolder": "Create folder",
|
||||
"CreateLink": "Create link",
|
||||
"CreateSuccess": "Success",
|
||||
"CurrentUser": "Current user",
|
||||
"Custom Setting": "Custom Setting",
|
||||
@@ -25,12 +29,17 @@
|
||||
"EndFileTransfer": "File transfer end",
|
||||
"ExceedTransferSize": "exceed max transfer size",
|
||||
"Expand": "Expand",
|
||||
"ExpiredTime": "Expired",
|
||||
"ExpiredTime": "Expiration time",
|
||||
"FailedCreateConnection": "Failed to create connection",
|
||||
"FileAlreadyExists": "File already exists",
|
||||
"FileListError": "Failed to get file list",
|
||||
"FileManagement": "File",
|
||||
"FileManagement": "File Management",
|
||||
"FileManagementExpired": "The current file management session has expired.",
|
||||
"FileTransferInterrupted": "File transfer interrupted",
|
||||
"FileUploadInterrupted": "File upload interrupted",
|
||||
"Format": "Format",
|
||||
"General": "General",
|
||||
"GetFileManagerTokenTimeOut": "Get file manager token timeout",
|
||||
"GetShareUser": "Enter username",
|
||||
"Hotkeys": "Hotkeys",
|
||||
"InputVerifyCode": "Input Verify Code",
|
||||
@@ -40,7 +49,7 @@
|
||||
"LastModified": "Last Modified",
|
||||
"LeaveShare": "Leave Session",
|
||||
"LeftArrow": "Left arrow",
|
||||
"LinkAddr": "Link",
|
||||
"LinkAddr": "Link Address",
|
||||
"List": "List",
|
||||
"Minute": "Minute",
|
||||
"Minutes": "Minutes",
|
||||
@@ -48,22 +57,26 @@
|
||||
"MustSelectOneFile": "Must select one file",
|
||||
"Name": "Name",
|
||||
"NewFolder": "New Folder",
|
||||
"NoActiveTerminalTabFound": "No active terminal tab found",
|
||||
"NoData": "No data",
|
||||
"NoLink": "No Link",
|
||||
"NoOnlineUser": "No online user",
|
||||
"OnlineUser": "Online user",
|
||||
"OnlineUsers": "Online Users",
|
||||
"NoRunningTerminalFound": "No running terminal found",
|
||||
"OnlineUser": "Online User",
|
||||
"OperationSuccessful": "Operation successful",
|
||||
"Paste": "Paste",
|
||||
"PauseSession": "Pause Session",
|
||||
"PermissionDenied": "Permission denied",
|
||||
"PermissionExpired": "Permission expired",
|
||||
"PermissionValid": "Permission valid",
|
||||
"ReadOnly": "Read-Only",
|
||||
"PleaseInput": "Please input",
|
||||
"PleaseInputVerifyCode": "Please input verify code",
|
||||
"PrimaryUser": "Primary user",
|
||||
"ReadOnly": "Read Only",
|
||||
"Reconnect": "Reconnect",
|
||||
"Refresh": "Refresh",
|
||||
"Remove": "Remove",
|
||||
"RemoveShareUser": "You have been removed from the shared session.",
|
||||
"RemoveShareUserConfirm": "Are you sure to remove the user from the shared session?",
|
||||
"RemoveUser": "Remove User",
|
||||
"Rename": "Rename",
|
||||
"ResumeSession": "Resume Session",
|
||||
"RightArrow": "Right arrow",
|
||||
@@ -71,20 +84,25 @@
|
||||
"SelectAction": "Select",
|
||||
"SelectTheme": "Select Theme",
|
||||
"Self": "Self",
|
||||
"SessionDetail": "Session Detail",
|
||||
"SessionShare": "Session Share",
|
||||
"Settings": "Settings",
|
||||
"Share": "Share",
|
||||
"ShareUser": "ForUser",
|
||||
"ShareLink": "Share Link",
|
||||
"ShareUser": "Share User",
|
||||
"ShareUserHelpText": "If left blank, everyone could join the session.",
|
||||
"Size": "Size",
|
||||
"Sync": "Sync",
|
||||
"SyncUserPreferenceFailed": "Sync user preference failed",
|
||||
"SyncUserPreferenceSuccess": "Sync user preference success",
|
||||
"TerminalInstanceNotFoundForCurrentTab": "Terminal instance not found for current tab",
|
||||
"TheCurrentTerminalInstanceWasNotFound": "The current terminal instance was not found",
|
||||
"Theme": "Theme",
|
||||
"ThemeColors": "Theme Colors",
|
||||
"ThemeConfig": "Theme",
|
||||
"ThemeSyncSuccessful": "Theme sync successful",
|
||||
"TransferHistory": "Transfer history",
|
||||
"Transfer": "Transfer",
|
||||
"Type": "Type",
|
||||
"UnableToGenerateWebSocketURL": "Unable to generate WebSocket URL, missing parameters",
|
||||
"UpArrow": "Up arrow",
|
||||
"Upload": "Upload",
|
||||
"UploadEnd": "Upload completed, please wait for further processing",
|
||||
@@ -92,11 +110,12 @@
|
||||
"UploadStart": "Upload start",
|
||||
"UploadSuccess": "Upload success",
|
||||
"UploadTips": "Drag file here or click to upload",
|
||||
"UploadTitle": "file upload",
|
||||
"UploadTitle": "File upload",
|
||||
"User": "User",
|
||||
"VerifyCode": "Verify Code",
|
||||
"WaitFileTransfer": "Wait file transfer to finish",
|
||||
"Warning": "Warning",
|
||||
"WebSocketClosed": "WebSocket closed",
|
||||
"WebSocketConnectionIsClosedHelpText": "WebSocket connection is closed, please refresh the page or reconnect.",
|
||||
"Writable": "Writable"
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"ActionPerm": "Permisos de operación",
|
||||
"ActionPerm": "Permisos de acción",
|
||||
"AlreadyExistsPleaseRename": "El archivo ya existe, por favor renombrar",
|
||||
"AvailableShortcutKey": "Atajos disponibles",
|
||||
"Back": "Regresar",
|
||||
"Cancel": "Cancelar",
|
||||
"CancelFileUpload": "Cancelar la subida del archivo",
|
||||
"Clone Connect": "Copiar ventana",
|
||||
@@ -11,7 +14,8 @@
|
||||
"Connect": "Conectar",
|
||||
"CopyLink": "Copiar enlace y código de verificación",
|
||||
"CopyShareURLSuccess": "Dirección de compartición copiada con éxito",
|
||||
"CreateLink": "Crear enlace compartido",
|
||||
"CreateFolder": "Crear carpeta",
|
||||
"CreateLink": "Crear enlace para compartir",
|
||||
"CreateSuccess": "Creación exitosa",
|
||||
"CurrentUser": "Usuario actual",
|
||||
"Custom Setting": "Ajustes personalizados",
|
||||
@@ -25,12 +29,17 @@
|
||||
"EndFileTransfer": "Transferencia de archivos finalizada",
|
||||
"ExceedTransferSize": "Superado el tamaño máximo de transferencia",
|
||||
"Expand": "Expandir",
|
||||
"ExpiredTime": "Fecha de caducidad",
|
||||
"ExpiredTime": "Fecha de validez",
|
||||
"FailedCreateConnection": "Fallo al crear conexión",
|
||||
"FileAlreadyExists": "El archivo ya existe",
|
||||
"FileListError": "No se pudo obtener la información de la lista de archivos",
|
||||
"FileManagement": "Gestión de archivos",
|
||||
"FileManagementExpired": "La sesión actual de gestión de archivos ha expirado.",
|
||||
"FileTransferInterrupted": "Transferencia de archivos interrumpida",
|
||||
"FileUploadInterrupted": "La subida del archivo se ha interrumpido",
|
||||
"Format": "Formato",
|
||||
"General": "General",
|
||||
"GetFileManagerTokenTimeOut": "Tiempo de espera para obtener el token de gestión de archivos",
|
||||
"GetShareUser": "Introducir nombre de usuario",
|
||||
"Hotkeys": "Atajos",
|
||||
"InputVerifyCode": "Por favor, ingrese el código de verificación",
|
||||
@@ -43,27 +52,31 @@
|
||||
"LinkAddr": "Dirección del enlace",
|
||||
"List": "Lista",
|
||||
"Minute": "Minutos",
|
||||
"Minutes": "Minutos",
|
||||
"Minutes": "Parte",
|
||||
"MustOneFile": "Solo se puede seleccionar un archivo",
|
||||
"MustSelectOneFile": "Debe seleccionar un archivo",
|
||||
"Name": "Nombre",
|
||||
"NewFolder": "Nueva carpeta",
|
||||
"NoActiveTerminalTabFound": "No se encontró una pestaña de terminal activa",
|
||||
"NoData": "Sin datos",
|
||||
"NoLink": "Sin dirección",
|
||||
"NoOnlineUser": "No hay usuarios en línea",
|
||||
"NoRunningTerminalFound": "No se encontró ningún terminal en ejecución",
|
||||
"OnlineUser": "Usuarios en línea",
|
||||
"OnlineUsers": "Personas en línea",
|
||||
"OperationSuccessful": "La acción se realizó con éxito",
|
||||
"Paste": "Pegar",
|
||||
"PauseSession": "Pausar esta sesión",
|
||||
"PermissionDenied": "Sin permiso",
|
||||
"PermissionExpired": "Los permisos han expirado",
|
||||
"PermissionValid": "Permisos válidos",
|
||||
"PleaseInput": "Por favor, ingrese.",
|
||||
"PleaseInputVerifyCode": "Por favor, ingresa el código de verificación",
|
||||
"PrimaryUser": "Usuario principal",
|
||||
"ReadOnly": "Solo lectura",
|
||||
"Reconnect": "Reconectar",
|
||||
"Refresh": "Refrescar",
|
||||
"Remove": "Eliminar",
|
||||
"RemoveShareUser": "Has sido eliminado de la sesión compartida",
|
||||
"RemoveShareUserConfirm": "¿Está seguro de que desea eliminar a este usuario?",
|
||||
"RemoveUser": "Eliminar usuario",
|
||||
"Rename": "Renombrar",
|
||||
"ResumeSession": "Restaurar esta sesión",
|
||||
"RightArrow": "Flecha hacia adelante",
|
||||
@@ -71,20 +84,25 @@
|
||||
"SelectAction": "Por favor selecciona",
|
||||
"SelectTheme": "Por favor, selecciona un tema",
|
||||
"Self": "Yo",
|
||||
"SessionDetail": "Detalles de la conversación.",
|
||||
"SessionShare": "Compartir conversación",
|
||||
"Settings": "Ajustes",
|
||||
"Share": "Compartir",
|
||||
"ShareLink": "Compartir enlace",
|
||||
"ShareUser": "Compartir usuario",
|
||||
"ShareUserHelpText": "No se ha seleccionado un usuario, lo que permite la entrada de todos",
|
||||
"Size": "Tamaño",
|
||||
"Sync": "Sincronizar",
|
||||
"SyncUserPreferenceFailed": "Falló la sincronización de ajustes",
|
||||
"SyncUserPreferenceSuccess": "Sincronización de ajustes exitosa",
|
||||
"TerminalInstanceNotFoundForCurrentTab": "La pestaña actual no encontró una instancia de terminal",
|
||||
"TheCurrentTerminalInstanceWasNotFound": "No se encontró la instancia de terminal actual",
|
||||
"Theme": "Tema",
|
||||
"ThemeColors": "Color del tema",
|
||||
"ThemeConfig": "Tema",
|
||||
"ThemeSyncSuccessful": "Sincronización del tema exitosa",
|
||||
"TransferHistory": "Transmisión de historial",
|
||||
"Transfer": "Transmisión",
|
||||
"Type": "Tipo",
|
||||
"UnableToGenerateWebSocketURL": "No se puede generar la URL de WebSocket, faltan parámetros",
|
||||
"UpArrow": "Flecha hacia arriba",
|
||||
"Upload": "Subir",
|
||||
"UploadEnd": "La subida ha finalizado, por favor espera el procesamiento posterior",
|
||||
@@ -94,9 +112,10 @@
|
||||
"UploadTips": "Arrastra el archivo aquí, o haz clic para subir",
|
||||
"UploadTitle": "Subir archivo",
|
||||
"User": "Usuario",
|
||||
"VerifyCode": "Código de verificación",
|
||||
"VerifyCode": "Código de verificación.",
|
||||
"WaitFileTransfer": "Esperando que finalice la transferencia de archivos",
|
||||
"Warning": "Advertencia",
|
||||
"WebSocketClosed": "WebSocket cerrado",
|
||||
"Writable": "Editable"
|
||||
"WebSocketConnectionIsClosedHelpText": "La conexión WebSocket se ha cerrado, por favor actualiza la página o reconéctate.",
|
||||
"Writable": "Se puede escribir"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user