Browse Source

Merge pull request #14511 from jumpserver/dev

v4.4.0
master
Bryan 10 hours ago committed by GitHub
parent
commit
25987545db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 24
      .github/workflows/discord-release.yml
  2. 28
      .github/workflows/llm-code-review.yml
  3. 13
      Dockerfile
  4. 18
      Dockerfile-base
  5. 13
      Dockerfile-ee
  6. 2
      apps/accounts/automations/change_secret/host/windows_rdp_verify/main.yml
  7. 2
      apps/accounts/automations/push_account/host/windows_rdp_verify/main.yml
  8. 16
      apps/accounts/backends/__init__.py
  9. 1
      apps/accounts/backends/azure/__init__.py
  10. 70
      apps/accounts/backends/azure/entries.py
  11. 57
      apps/accounts/backends/azure/main.py
  12. 59
      apps/accounts/backends/azure/service.py
  13. 8
      apps/accounts/backends/base.py
  14. 6
      apps/accounts/backends/hcp/main.py
  15. 2
      apps/accounts/backends/local/main.py
  16. 4
      apps/accounts/const/automation.py
  17. 1
      apps/accounts/const/vault.py
  18. 4
      apps/accounts/migrations/0002_auto_20220616_0021.py
  19. 2
      apps/accounts/models/automations/base.py
  20. 5
      apps/accounts/models/automations/push_account.py
  21. 20
      apps/accounts/serializers/automations/change_secret.py
  22. 20
      apps/accounts/signal_handlers.py
  23. 4
      apps/accounts/tasks/vault.py
  24. 4
      apps/assets/api/asset/asset.py
  25. 9
      apps/assets/const/types.py
  26. 37
      apps/audits/api.py
  27. 26
      apps/audits/serializers.py
  28. 2
      apps/audits/signal_handlers/operate_log.py
  29. 4
      apps/audits/urls/api_urls.py
  30. 103
      apps/authentication/api/mfa.py
  31. 12
      apps/authentication/api/password.py
  32. 44
      apps/authentication/backends/ldap.py
  33. 1
      apps/authentication/confirm/mfa.py
  34. 8
      apps/authentication/const.py
  35. 1
      apps/authentication/mfa/__init__.py
  36. 4
      apps/authentication/mfa/base.py
  37. 57
      apps/authentication/mfa/face.py
  38. 2
      apps/authentication/mfa/radius.py
  39. 10
      apps/authentication/mfa/sms.py
  40. 2
      apps/authentication/middleware.py
  41. 49
      apps/authentication/mixins.py
  42. 14
      apps/authentication/serializers/password_mfa.py
  43. 92
      apps/authentication/templates/authentication/face_capture.html
  44. 2
      apps/authentication/urls/api_urls.py
  45. 3
      apps/authentication/urls/view_urls.py
  46. 2
      apps/authentication/views/login.py
  47. 18
      apps/authentication/views/mfa.py
  48. 18
      apps/authentication/views/wecom.py
  49. 29
      apps/common/const/choices.py
  50. 2
      apps/common/drf/parsers/base.py
  51. 1
      apps/common/management/commands/check_api.py
  52. 6
      apps/common/sdk/im/utils.py
  53. 74
      apps/common/sdk/im/wecom/__init__.py
  54. 7
      apps/common/sdk/sms/endpoint.py
  55. 2
      apps/common/storage/base.py
  56. 41
      apps/common/storage/jms_storage/__init__.py
  57. 61
      apps/common/storage/jms_storage/azure.py
  58. 51
      apps/common/storage/jms_storage/base.py
  59. 68
      apps/common/storage/jms_storage/ceph.py
  60. 116
      apps/common/storage/jms_storage/ftp.py
  61. 50
      apps/common/storage/jms_storage/jms.py
  62. 77
      apps/common/storage/jms_storage/multi.py
  63. 70
      apps/common/storage/jms_storage/obs.py
  64. 72
      apps/common/storage/jms_storage/oss.py
  65. 74
      apps/common/storage/jms_storage/s3.py
  66. 109
      apps/common/storage/jms_storage/sftp.py
  67. 2
      apps/common/tasks.py
  68. 6
      apps/common/utils/connection.py
  69. 12
      apps/common/utils/verify_code.py
  70. 2
      apps/i18n/_translator/base.py
  71. 848
      apps/i18n/core/en/LC_MESSAGES/django.po
  72. 865
      apps/i18n/core/ja/LC_MESSAGES/django.po
  73. 964
      apps/i18n/core/zh/LC_MESSAGES/django.po
  74. 862
      apps/i18n/core/zh_Hant/LC_MESSAGES/django.po
  75. 4
      apps/i18n/koko/en.json
  76. 4
      apps/i18n/koko/ja.json
  77. 2
      apps/i18n/koko/zh.json
  78. 4
      apps/i18n/koko/zh_hant.json
  79. 35
      apps/i18n/lina/en.json
  80. 36
      apps/i18n/lina/ja.json
  81. 38
      apps/i18n/lina/zh.json
  82. 39
      apps/i18n/lina/zh_hant.json
  83. 1
      apps/i18n/luna/en.json
  84. 1
      apps/i18n/luna/ja.json
  85. 1
      apps/i18n/luna/zh.json
  86. 1
      apps/i18n/luna/zh_hant.json
  87. 14
      apps/jumpserver/conf.py
  88. 3
      apps/jumpserver/settings/_xpack.py
  89. 15
      apps/jumpserver/settings/auth.py
  90. 2
      apps/jumpserver/settings/custom.py
  91. 2
      apps/jumpserver/views/other.py
  92. 15
      apps/notifications/notifications.py
  93. 6
      apps/ops/ansible/runner.py
  94. 1
      apps/ops/api/__init__.py
  95. 8
      apps/ops/api/adhoc.py
  96. 38
      apps/ops/api/job.py
  97. 7
      apps/ops/api/playbook.py
  98. 25
      apps/ops/api/variable.py
  99. 5
      apps/ops/const.py
  100. 28
      apps/ops/migrations/0004_job_nodes_alter_job_assets.py
  101. Some files were not shown because too many files have changed in this diff Show More

24
.github/workflows/discord-release.yml

@ -0,0 +1,24 @@
name: Publish Release to Discord
on:
release:
types: [published]
jobs:
send_discord_notification:
runs-on: ubuntu-latest
if: startsWith(github.event.release.tag_name, 'v4.')
steps:
- name: Send release notification to Discord
env:
WEBHOOK_URL: ${{ secrets.DISCORD_CHANGELOG_WEBHOOK }}
run: |
# 获取标签名称和 release body
TAG_NAME="${{ github.event.release.tag_name }}"
RELEASE_BODY="${{ github.event.release.body }}"
# 使用 jq 构建 JSON 数据,以确保安全传递
JSON_PAYLOAD=$(jq -n --arg tag "# JumpServer $TAG_NAME Released! 🚀" --arg body "$RELEASE_BODY" '{content: "\($tag)\n\($body)"}')
# 使用 curl 发送 JSON 数据
curl -X POST -H "Content-Type: application/json" -d "$JSON_PAYLOAD" "$WEBHOOK_URL"

28
.github/workflows/llm-code-review.yml

@ -0,0 +1,28 @@
name: LLM Code Review
permissions:
contents: read
pull-requests: write
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
llm-code-review:
runs-on: ubuntu-latest
steps:
- uses: fit2cloud/LLM-CodeReview-Action@main
env:
GITHUB_TOKEN: ${{ secrets.FIT2CLOUDRD_LLM_CODE_REVIEW_TOKEN }}
OPENAI_API_KEY: ${{ secrets.ALIYUN_LLM_API_KEY }}
LANGUAGE: English
OPENAI_API_ENDPOINT: https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL: qwen2-1.5b-instruct
PROMPT: "Please check the following code differences for any irregularities, potential issues, or optimization suggestions, and provide your answers in English."
top_p: 1
temperature: 1
# max_tokens: 10000
MAX_PATCH_LENGTH: 10000
IGNORE_PATTERNS: "/node_modules,*.md,/dist,/.github"
FILE_PATTERNS: "*.java,*.go,*.py,*.vue,*.ts,*.js,*.css,*.scss,*.html"

13
Dockerfile

@ -1,4 +1,4 @@
FROM jumpserver/core-base:20240924_031841 AS stage-build
FROM jumpserver/core-base:20241105_025649 AS stage-build
ARG VERSION
@ -24,10 +24,10 @@ ENV LANG=en_US.UTF-8 \
PATH=/opt/py3/bin:$PATH
ARG DEPENDENCIES=" \
libldap2-dev \
libx11-dev"
ARG TOOLS=" \
cron \
ca-certificates \
default-libmysqlclient-dev \
openssh-client \
@ -35,19 +35,20 @@ ARG TOOLS=" \
bubblewrap"
ARG APT_MIRROR=http://deb.debian.org
RUN set -ex \
&& rm -f /etc/apt/apt.conf.d/docker-clean \
&& sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& apt-get update > /dev/null \
&& apt-get -y install --no-install-recommends ${DEPENDENCIES} \
&& apt-get -y install --no-install-recommends ${TOOLS} \
&& apt-get clean \
&& mkdir -p /root/.ssh/ \
&& echo "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n\tCiphers +aes128-cbc\n\tKexAlgorithms +diffie-hellman-group1-sha1\n\tHostKeyAlgorithms +ssh-rsa" > /root/.ssh/config \
&& echo "no" | dpkg-reconfigure dash \
&& sed -i "s@# export @export @g" ~/.bashrc \
&& sed -i "s@# alias @alias @g" ~/.bashrc
&& apt-get clean all \
&& rm -rf /var/lib/apt/lists/* \
&& echo "0 3 * * * root find /tmp -type f -mtime +1 -size +1M -exec rm -f {} \; && date > /tmp/clean.log" > /etc/cron.d/cleanup_tmp \
&& chmod 0644 /etc/cron.d/cleanup_tmp
COPY --from=stage-build /opt /opt
COPY --from=stage-build /usr/local/bin /usr/local/bin

18
Dockerfile-base

@ -15,8 +15,8 @@ ARG DEPENDENCIES=" \
libldap2-dev \
libsasl2-dev"
ARG APT_MIRROR=http://deb.debian.org
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=core \
--mount=type=cache,target=/var/lib/apt,sharing=locked,id=core \
set -ex \
@ -27,9 +27,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=core \
&& apt-get -y install --no-install-recommends ${DEPENDENCIES} \
&& echo "no" | dpkg-reconfigure dash
# Install bin tools
ARG CHECK_VERSION=v1.0.3
ARG CHECK_VERSION=v1.0.4
RUN set -ex \
&& wget https://github.com/jumpserver-dev/healthcheck/releases/download/${CHECK_VERSION}/check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz \
&& tar -xf check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz \
@ -38,23 +37,24 @@ RUN set -ex \
&& chmod 755 /usr/local/bin/check \
&& rm -f check-${CHECK_VERSION}-linux-${TARGETARCH}.tar.gz
# Install Python dependencies
WORKDIR /opt/jumpserver
ARG PIP_MIRROR=https://pypi.org/simple
ENV POETRY_PYPI_MIRROR_URL=${PIP_MIRROR}
ENV ANSIBLE_COLLECTIONS_PATHS=/opt/py3/lib/python3.11/site-packages/ansible_collections
RUN --mount=type=cache,target=/root/.cache,sharing=locked,id=core \
RUN --mount=type=cache,target=/root/.cache \
--mount=type=bind,source=poetry.lock,target=poetry.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=utils/clean_site_packages.sh,target=clean_site_packages.sh \
--mount=type=bind,source=requirements/collections.yml,target=collections.yml \
set -ex \
&& python3 -m venv /opt/py3 \
&& pip install poetry -i ${PIP_MIRROR} \
&& poetry config virtualenvs.create false \
&& pip install poetry poetry-plugin-pypi-mirror -i ${PIP_MIRROR} \
&& . /opt/py3/bin/activate \
&& poetry install --only main \
&& poetry config virtualenvs.create false \
&& poetry install --no-cache --only main \
&& ansible-galaxy collection install -r collections.yml --force --ignore-certs \
&& bash clean_site_packages.sh
&& bash clean_site_packages.sh \
&& poetry cache clear pypi --all

13
Dockerfile-ee

@ -15,21 +15,20 @@ ARG TOOLS=" \
vim \
wget"
ARG APT_MIRROR=http://deb.debian.org
RUN set -ex \
&& rm -f /etc/apt/apt.conf.d/docker-clean \
&& echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \
&& sed -i "s@http://.*.debian.org@${APT_MIRROR}@g" /etc/apt/sources.list \
&& apt-get update \
&& apt-get -y install --no-install-recommends ${TOOLS} \
&& echo "no" | dpkg-reconfigure dash
&& apt-get clean all \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/jumpserver
ARG PIP_MIRROR=https://pypi.org/simple
ENV POETRY_PYPI_MIRROR_URL=${PIP_MIRROR}
COPY poetry.lock pyproject.toml ./
RUN set -ex \
&& . /opt/py3/bin/activate \
&& pip install poetry -i ${PIP_MIRROR} \
&& poetry install --only xpack
&& pip install poetry poetry-plugin-pypi-mirror -i ${PIP_MIRROR} \
&& poetry install --only xpack \
&& poetry cache clear pypi --all

2
apps/accounts/automations/change_secret/host/windows_rdp_verify/main.yml

@ -30,6 +30,6 @@
login_user: "{{ account.username }}"
login_password: "{{ account.secret }}"
login_secret_type: "{{ account.secret_type }}"
gateway_args: "{{ jms_gateway | default(None) }}"
gateway_args: "{{ jms_gateway | default({}) }}"
when: account.secret_type == "password"
delegate_to: localhost

2
apps/accounts/automations/push_account/host/windows_rdp_verify/main.yml

@ -30,6 +30,6 @@
login_user: "{{ account.username }}"
login_password: "{{ account.secret }}"
login_secret_type: "{{ account.secret_type }}"
gateway_args: "{{ jms_gateway | default(None) }}"
gateway_args: "{{ jms_gateway | default({}) }}"
when: account.secret_type == "password"
delegate_to: localhost

16
apps/accounts/backends/__init__.py

@ -1,19 +1,21 @@
from importlib import import_module
from django.utils.functional import LazyObject
from django.utils.functional import LazyObject, empty
from common.utils import get_logger
from ..const import VaultTypeChoices
__all__ = ['vault_client', 'get_vault_client']
__all__ = ['vault_client', 'get_vault_client', 'refresh_vault_client']
logger = get_logger(__file__)
def get_vault_client(raise_exception=False, **kwargs):
enabled = kwargs.get('VAULT_ENABLED')
tp = 'hcp' if enabled else 'local'
tp = kwargs.get('VAULT_BACKEND') if kwargs.get('VAULT_ENABLED') else VaultTypeChoices.local
# TODO: Temporary processing, subsequent deletion
tp = VaultTypeChoices.local if tp == VaultTypeChoices.azure else tp
try:
module_path = f'apps.accounts.backends.{tp}.main'
client = import_module(module_path).Vault(**kwargs)
@ -39,3 +41,7 @@ class VaultClient(LazyObject):
""" 为了安全, 页面修改配置, 重启服务后才会重新初始化 vault_client """
vault_client = VaultClient()
def refresh_vault_client():
vault_client._wrapped = empty

1
apps/accounts/backends/azure/__init__.py

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

70
apps/accounts/backends/azure/entries.py

@ -0,0 +1,70 @@
import sys
from abc import ABC
from common.db.utils import Encryptor
from common.utils import lazyproperty
current_module = sys.modules[__name__]
__all__ = ['build_entry']
class BaseEntry(ABC):
def __init__(self, instance):
self.instance = instance
@lazyproperty
def full_path(self):
return self.path_spec
@property
def path_spec(self):
raise NotImplementedError
def to_internal_data(self):
secret = getattr(self.instance, '_secret', None)
if secret is not None:
secret = Encryptor(secret).encrypt()
return secret
@staticmethod
def to_external_data(secret):
if secret is not None:
secret = Encryptor(secret).decrypt()
return secret
class AccountEntry(BaseEntry):
@property
def path_spec(self):
# 长度 0-127
account_id = str(self.instance.id)[:18]
path = f'assets-{self.instance.asset_id}-accounts-{account_id}'
return path
class AccountTemplateEntry(BaseEntry):
@property
def path_spec(self):
path = f'account-templates-{self.instance.id}'
return path
class HistoricalAccountEntry(BaseEntry):
@property
def path_spec(self):
path = f'accounts-{self.instance.instance.id}-histories-{self.instance.history_id}'
return path
def build_entry(instance) -> BaseEntry:
class_name = instance.__class__.__name__
entry_class_name = f'{class_name}Entry'
entry_class = getattr(current_module, entry_class_name, None)
if not entry_class:
raise Exception(f'Entry class {entry_class_name} is not found')
return entry_class(instance)

57
apps/accounts/backends/azure/main.py

@ -0,0 +1,57 @@
from common.db.utils import get_logger
from .entries import build_entry
from .service import AZUREVaultClient
from ..base import BaseVault
from ...const import VaultTypeChoices
logger = get_logger(__name__)
__all__ = ['Vault']
class Vault(BaseVault):
type = VaultTypeChoices.azure
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.client = AZUREVaultClient(
vault_url=kwargs.get('VAULT_AZURE_HOST'),
tenant_id=kwargs.get('VAULT_AZURE_TENANT_ID'),
client_id=kwargs.get('VAULT_AZURE_CLIENT_ID'),
client_secret=kwargs.get('VAULT_AZURE_CLIENT_SECRET')
)
def is_active(self):
return self.client.is_active()
def _get(self, instance):
entry = build_entry(instance)
secret = self.client.get(name=entry.full_path)
secret = entry.to_external_data(secret)
return secret
def _create(self, instance):
entry = build_entry(instance)
secret = entry.to_internal_data()
self.client.create(name=entry.full_path, secret=secret)
def _update(self, instance):
entry = build_entry(instance)
secret = entry.to_internal_data()
self.client.update(name=entry.full_path, secret=secret)
def _delete(self, instance):
entry = build_entry(instance)
self.client.delete(name=entry.full_path)
def _clean_db_secret(self, instance):
instance.is_sync_metadata = False
instance.mark_secret_save_to_vault()
def _save_metadata(self, instance, metadata):
try:
entry = build_entry(instance)
self.client.update_metadata(name=entry.full_path, metadata=metadata)
except Exception as e:
logger.error(f'save metadata error: {e}')

59
apps/accounts/backends/azure/service.py

@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
#
from azure.core.exceptions import ResourceNotFoundError, ClientAuthenticationError
from azure.identity import ClientSecretCredential
from azure.keyvault.secrets import SecretClient
from common.utils import get_logger
logger = get_logger(__name__)
__all__ = ['AZUREVaultClient']
class AZUREVaultClient(object):
def __init__(self, vault_url, tenant_id, client_id, client_secret):
authentication_endpoint = 'https://login.microsoftonline.com/' \
if ('azure.net' in vault_url) else 'https://login.chinacloudapi.cn/'
credentials = ClientSecretCredential(
client_id=client_id, client_secret=client_secret, tenant_id=tenant_id, authority=authentication_endpoint
)
self.client = SecretClient(vault_url=vault_url, credential=credentials)
def is_active(self):
try:
self.client.set_secret('jumpserver', '666')
except (ResourceNotFoundError, ClientAuthenticationError) as e:
logger.error(str(e))
return False, f'Vault is not reachable: {e}'
else:
return True, ''
def get(self, name, version=None):
try:
secret = self.client.get_secret(name, version)
return secret.value
except (ResourceNotFoundError, ClientAuthenticationError) as e:
logger.error(f'get: {name} {str(e)}')
return ''
def create(self, name, secret):
if not secret:
secret = ''
self.client.set_secret(name, secret)
def update(self, name, secret):
if not secret:
secret = ''
self.client.set_secret(name, secret)
def delete(self, name):
self.client.begin_delete_secret(name)
def update_metadata(self, name, metadata: dict):
try:
self.client.update_secret_properties(name, tags=metadata)
except (ResourceNotFoundError, ClientAuthenticationError) as e:
logger.error(f'update_metadata: {name} {str(e)}')

8
apps/accounts/backends/base.py

@ -10,6 +10,11 @@ class BaseVault(ABC):
def __init__(self, *args, **kwargs):
self.enabled = kwargs.get('VAULT_ENABLED')
@property
@abstractmethod
def type(self):
raise NotImplementedError
def get(self, instance):
""" 返回 secret 值 """
return self._get(instance)
@ -20,9 +25,6 @@ class BaseVault(ABC):
self._clean_db_secret(instance)
self.save_metadata(instance)
if instance.is_sync_metadata:
self.save_metadata(instance)
def update(self, instance):
if not instance.secret_has_save_to_vault:
self._update(instance)

6
apps/accounts/backends/hcp/main.py

@ -3,12 +3,16 @@ from .entries import build_entry
from .service import VaultKVClient
from ..base import BaseVault
__all__ = ['Vault']
from ...const import VaultTypeChoices
logger = get_logger(__name__)
__all__ = ['Vault']
class Vault(BaseVault):
type = VaultTypeChoices.hcp
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.client = VaultKVClient(

2
apps/accounts/backends/local/main.py

@ -1,5 +1,6 @@
from common.utils import get_logger
from ..base import BaseVault
from ...const import VaultTypeChoices
logger = get_logger(__name__)
@ -7,6 +8,7 @@ __all__ = ['Vault']
class Vault(BaseVault):
type = VaultTypeChoices.local
def is_active(self):
return True, ''

4
apps/accounts/const/automation.py

@ -49,9 +49,9 @@ class SecretStrategy(models.TextChoices):
class SSHKeyStrategy(models.TextChoices):
add = 'add', _('Append SSH KEY')
set = 'set', _('Empty and append SSH KEY')
set_jms = 'set_jms', _('Replace (Replace only keys pushed by JumpServer) ')
set = 'set', _('Empty and append SSH KEY')
add = 'add', _('Append SSH KEY')
class TriggerChoice(models.TextChoices, TreeChoices):

1
apps/accounts/const/vault.py

@ -7,3 +7,4 @@ __all__ = ['VaultTypeChoices']
class VaultTypeChoices(models.TextChoices):
local = 'local', _('Database')
hcp = 'hcp', _('HCP Vault')
azure = 'azure', _('Azure Key Vault')

4
apps/accounts/migrations/0002_auto_20220616_0021.py

@ -50,7 +50,7 @@ class Migration(migrations.Migration):
('secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Secret')),
('secret_strategy', models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy')),
('password_rules', models.JSONField(default=dict, verbose_name='Password rules')),
('ssh_key_change_strategy', models.CharField(choices=[('add', 'Append SSH KEY'), ('set', 'Empty and append SSH KEY'), ('set_jms', 'Replace (Replace only keys pushed by JumpServer) ')], default='add', max_length=16, verbose_name='SSH key change strategy')),
('ssh_key_change_strategy', models.CharField(choices=[('set_jms', 'Replace (Replace only keys pushed by JumpServer) '), ('set', 'Empty and append SSH KEY'), ('add', 'Append SSH KEY')], default='set_jms', max_length=16, verbose_name='SSH key change strategy')),
],
options={
'verbose_name': 'Change secret automation',
@ -76,7 +76,7 @@ class Migration(migrations.Migration):
('secret', common.db.fields.EncryptTextField(blank=True, null=True, verbose_name='Secret')),
('secret_strategy', models.CharField(choices=[('specific', 'Specific secret'), ('random', 'Random generate')], default='specific', max_length=16, verbose_name='Secret strategy')),
('password_rules', models.JSONField(default=dict, verbose_name='Password rules')),
('ssh_key_change_strategy', models.CharField(choices=[('add', 'Append SSH KEY'), ('set', 'Empty and append SSH KEY'), ('set_jms', 'Replace (Replace only keys pushed by JumpServer) ')], default='add', max_length=16, verbose_name='SSH key change strategy')),
('ssh_key_change_strategy', models.CharField(choices=[('set_jms', 'Replace (Replace only keys pushed by JumpServer) '), ('set', 'Empty and append SSH KEY'), ('add', 'Append SSH KEY')], default='set_jms', max_length=16, verbose_name='SSH key change strategy')),
('triggers', models.JSONField(default=list, max_length=16, verbose_name='Triggers')),
('username', models.CharField(max_length=128, verbose_name='Username')),
('action', models.CharField(max_length=16, verbose_name='Action')),

2
apps/accounts/models/automations/base.py

@ -51,7 +51,7 @@ class AutomationExecution(AssetAutomationExecution):
class ChangeSecretMixin(SecretWithRandomMixin):
ssh_key_change_strategy = models.CharField(
choices=SSHKeyStrategy.choices, max_length=16,
default=SSHKeyStrategy.add, verbose_name=_('SSH key change strategy')
default=SSHKeyStrategy.set_jms, verbose_name=_('SSH key change strategy')
)
get_all_assets: callable # get all assets

5
apps/accounts/models/automations/push_account.py

@ -2,7 +2,7 @@ from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
from accounts.const import AutomationTypes
from accounts.const import AutomationTypes, SecretType
from accounts.models import Account
from .base import AccountBaseAutomation
from .change_secret import ChangeSecretMixin
@ -23,7 +23,8 @@ class PushAccountAutomation(ChangeSecretMixin, AccountBaseAutomation):
create_usernames = set(usernames) - set(account_usernames)
create_account_objs = [
Account(
name=f'{username}-{secret_type}', username=username,
name=f"{username}-{secret_type}" if secret_type != SecretType.PASSWORD else username,
username=username,
secret_type=secret_type, asset=asset,
)
for username in create_usernames

20
apps/accounts/serializers/automations/change_secret.py

@ -63,6 +63,26 @@ class ChangeSecretAutomationSerializer(AuthValidateMixin, BaseAutomationSerializ
)},
}}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.set_ssh_key_change_strategy_choices()
def set_ssh_key_change_strategy_choices(self):
ssh_key_change_strategy = self.fields.get("ssh_key_change_strategy")
if not ssh_key_change_strategy:
return
ssh_key_change_strategy._choices.pop(SSHKeyStrategy.add, None)
def to_representation(self, instance):
data = super().to_representation(instance)
ssh_strategy_value = data.get('ssh_key_change_strategy', {}).get('value')
if ssh_strategy_value == SSHKeyStrategy.add:
data['ssh_key_change_strategy'] = {
'label': SSHKeyStrategy.set_jms.label,
'value': SSHKeyStrategy.set_jms.value
}
return data
@property
def model_type(self):
return AutomationTypes.change_secret

20
apps/accounts/signal_handlers.py

@ -3,14 +3,17 @@ from collections import defaultdict
from django.db.models.signals import post_delete
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from django.utils.functional import LazyObject
from django.utils.translation import gettext_noop
from accounts.backends import vault_client
from accounts.backends import vault_client, refresh_vault_client
from accounts.const import Source
from audits.const import ActivityChoices
from audits.signal_handlers import create_activities
from common.decorators import merge_delay_run
from common.signals import django_ready
from common.utils import get_logger, i18n_fmt
from common.utils.connection import RedisPubSub
from .models import Account, AccountTemplate
from .tasks.push_account import push_accounts_to_assets_task
@ -91,3 +94,18 @@ class VaultSignalHandler(object):
for model in (Account, AccountTemplate, Account.history.model):
post_save.connect(VaultSignalHandler.save_to_vault, sender=model)
post_delete.connect(VaultSignalHandler.delete_to_vault, sender=model)
class VaultPubSub(LazyObject):
def _setup(self):
self._wrapped = RedisPubSub('refresh_vault')
vault_pub_sub = VaultPubSub()
@receiver(django_ready)
def subscribe_vault_change(sender, **kwargs):
logger.debug("Start subscribe vault change")
vault_pub_sub.subscribe(lambda name: refresh_vault_client())

4
apps/accounts/tasks/vault.py

@ -5,6 +5,7 @@ from celery import shared_task
from django.utils.translation import gettext_lazy as _
from accounts.backends import vault_client
from accounts.const import VaultTypeChoices
from accounts.models import Account, AccountTemplate
from common.utils import get_logger
from orgs.utils import tmp_to_root_org
@ -39,6 +40,9 @@ def sync_secret_to_vault():
# 这里不能判断 settings.VAULT_ENABLED, 必须判断当前 vault_client 的类型
print('\033[35m>>> 当前 Vault 功能未开启, 不需要同步')
return
if VaultTypeChoices.local == vault_client.type:
print('\033[31m>>> 当前第三方 Vault 客户端初始化失败,数据存储在本地数据库')
return
failed, skipped, succeeded = 0, 0, 0
to_sync_models = [Account, AccountTemplate, Account.history.model]

4
apps/assets/api/asset/asset.py

@ -123,6 +123,10 @@ class AssetViewSet(SuggestionMixin, OrgBulkModelViewSet):
NodeFilterBackend, AttrRulesFilterBackend
]
def perform_destroy(self, instance):
instance.accounts.update(su_from_id=None)
instance.delete()
def get_queryset(self):
queryset = super().get_queryset()
if queryset.model is not Asset:

9
apps/assets/const/types.py

@ -3,6 +3,7 @@ from collections import defaultdict
from copy import deepcopy
from django.conf import settings
from django.utils.functional import lazy
from django.utils.translation import gettext as _
from common.db.models import ChoicesMixin
@ -29,15 +30,15 @@ class AllTypes(ChoicesMixin):
@classmethod
def choices(cls):
return lazy(cls.get_choices, list)()
@classmethod
def get_choices(cls):
choices = []
for tp in cls.includes:
choices.extend(tp.get_choices())
return choices
@classmethod
def get_choices(cls):
return cls.choices()
@classmethod
def filter_choices(cls, category):
choices = dict(cls.category_types()).get(category, cls).get_choices()

37
apps/audits/api.py

@ -7,6 +7,7 @@ from django.db.models import F, Value, CharField, Q
from django.db.models.functions import Cast
from django.http import HttpResponse, FileResponse
from django.utils.encoding import escape_uri_path
from django_celery_beat.models import PeriodicTask
from rest_framework import generics
from rest_framework import status
from rest_framework import viewsets
@ -22,6 +23,9 @@ from common.plugins.es import QuerySet as ESQuerySet
from common.sessions.cache import user_session_manager
from common.storage.ftp_file import FTPFileStorageHandler
from common.utils import is_uuid, get_logger, lazyproperty
from ops.const import Types
from ops.models import Job
from ops.serializers.job import JobSerializer
from orgs.mixins.api import OrgReadonlyModelViewSet, OrgModelViewSet
from orgs.models import Organization
from orgs.utils import current_org, tmp_to_root_org
@ -39,14 +43,14 @@ from .serializers import (
FTPLogSerializer, UserLoginLogSerializer, JobLogSerializer,
OperateLogSerializer, OperateLogActionDetailSerializer,
PasswordChangeLogSerializer, ActivityUnionLogSerializer,
FileSerializer, UserSessionSerializer
FileSerializer, UserSessionSerializer, JobsAuditSerializer
)
from .utils import construct_userlogin_usernames
logger = get_logger(__name__)
class JobAuditViewSet(OrgReadonlyModelViewSet):
class JobLogAuditViewSet(OrgReadonlyModelViewSet):
model = JobLog
extra_filter_backends = [DatetimeRangeFilterBackend]
date_range_filter_fields = [
@ -58,6 +62,35 @@ class JobAuditViewSet(OrgReadonlyModelViewSet):
ordering = ['-date_start']
class JobsAuditViewSet(OrgModelViewSet):
model = Job
search_fields = ['creator__name']
filterset_fields = ['creator__name']
serializer_class = JobsAuditSerializer
ordering = ['-is_periodic', '-date_updated']
http_method_names = ['get', 'options', 'patch']
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.exclude(type=Types.upload_file).filter(instant=False)
return queryset
def perform_update(self, serializer):
job = self.get_object()
is_periodic = serializer.validated_data.get('is_periodic')
if job.is_periodic != is_periodic:
job.is_periodic = is_periodic
job.save()
name, task, args, kwargs = job.get_register_task()
task_obj = PeriodicTask.objects.filter(name=name).first()
if task_obj:
is_periodic = job.is_periodic
if task_obj.enabled != is_periodic:
task_obj.enabled = is_periodic
task_obj.save()
return super().perform_update(serializer)
class FTPLogViewSet(OrgModelViewSet):
model = FTPLog
serializer_class = FTPLogSerializer

26
apps/audits/serializers.py

@ -7,7 +7,7 @@ from audits.backends.db import OperateLogStore
from common.serializers.fields import LabeledChoiceField, ObjectRelatedField
from common.utils import reverse, i18n_trans
from common.utils.timezone import as_current_tz
from ops.serializers.job import JobExecutionSerializer
from ops.serializers.job import JobExecutionSerializer, JobSerializer
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from terminal.models import Session
from users.models import User
@ -34,6 +34,30 @@ class JobLogSerializer(JobExecutionSerializer):
}
class JobsAuditSerializer(JobSerializer):
material = serializers.ReadOnlyField(label=_("Command"))
summary = serializers.ReadOnlyField(label=_("Summary"))
crontab = serializers.ReadOnlyField(label=_("Execution cycle"))
is_periodic_display = serializers.BooleanField(read_only=True, source='is_periodic')
class Meta(JobSerializer.Meta):
read_only_fields = [
"id", 'name', 'args', 'material', 'type', 'crontab', 'interval', 'date_last_run', 'summary', 'created_by',
'is_periodic_display'
]
fields = read_only_fields + ['is_periodic']
def validate(self, attrs):
allowed_fields = {'is_periodic'}
submitted_fields = set(attrs.keys())
invalid_fields = submitted_fields - allowed_fields
if invalid_fields:
raise serializers.ValidationError(
f"Updating {', '.join(invalid_fields)} fields is not allowed"
)
return attrs
class FTPLogSerializer(serializers.ModelSerializer):
operate = LabeledChoiceField(choices=OperateChoices.choices, label=_("Operate"))

2
apps/audits/signal_handlers/operate_log.py

@ -187,7 +187,7 @@ def on_django_start_set_operate_log_monitor_models(sender, **kwargs):
'PermedAsset', 'PermedAccount', 'MenuPermission',
'Permission', 'TicketSession', 'ApplyLoginTicket',
'ApplyCommandTicket', 'ApplyLoginAssetTicket',
'FavoriteAsset', 'ChangeSecretRecord', 'AppProvider',
'FavoriteAsset', 'ChangeSecretRecord', 'AppProvider', 'Variable'
}
include_models = {'UserSession'}
for i, app in enumerate(apps.get_models(), 1):

4
apps/audits/urls/api_urls.py

@ -13,7 +13,9 @@ router.register(r'ftp-logs', api.FTPLogViewSet, 'ftp-log')
router.register(r'login-logs', api.UserLoginLogViewSet, 'login-log')
router.register(r'operate-logs', api.OperateLogViewSet, 'operate-log')
router.register(r'password-change-logs', api.PasswordChangeLogViewSet, 'password-change-log')
router.register(r'job-logs', api.JobAuditViewSet, 'job-log')
router.register(r'job-logs', api.JobLogAuditViewSet, 'job-log')
router.register(r'jobs', api.JobsAuditViewSet, 'jobs')
router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log')
router.register(r'user-sessions', api.UserSessionViewSet, 'user-session')

103
apps/authentication/api/mfa.py

@ -1,29 +1,128 @@
# -*- coding: utf-8 -*-
#
import uuid
from django.core.cache import cache
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from rest_framework import exceptions
from rest_framework.generics import CreateAPIView
from rest_framework.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.exceptions import NotFound
from common.exceptions import JMSException, UnexpectError
from common.permissions import WithBootstrapToken, IsServiceAccount
from common.utils import get_logger
from users.models.user import User
from .. import errors
from .. import serializers
from ..const import MFA_FACE_CONTEXT_CACHE_KEY_PREFIX, MFA_FACE_SESSION_KEY, MFA_FACE_CONTEXT_CACHE_TTL
from ..errors import SessionEmptyError
from ..mixins import AuthMixin
logger = get_logger(__name__)
__all__ = [
'MFAChallengeVerifyApi', 'MFASendCodeApi'
'MFAChallengeVerifyApi', 'MFASendCodeApi',
'MFAFaceCallbackApi', 'MFAFaceContextApi'
]
class MFAFaceCallbackApi(AuthMixin, CreateAPIView):
permission_classes = (IsServiceAccount,)
serializer_class = serializers.MFAFaceCallbackSerializer
def perform_create(self, serializer):
token = serializer.validated_data.get('token')
context = self._get_context_from_cache(token)
if not serializer.validated_data.get('success', False):
self._update_context_with_error(
context,
serializer.validated_data.get('error_message', 'Unknown error')
)
return Response(status=200)
face_code = serializer.validated_data.get('face_code')
if not face_code:
self._update_context_with_error(context, "missing field 'face_code'")
raise ValidationError({'error': "missing field 'face_code'"})
self._handle_success(context, face_code)
return Response(status=200)
@staticmethod
def get_face_cache_key(token):
return f"{MFA_FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
def _get_context_from_cache(self, token):
cache_key = self.get_face_cache_key(token)
context = cache.get(cache_key)
if not context:
raise ValidationError({'error': "token not exists or expired"})
return context
def _update_context_with_error(self, context, error_message):
context.update({
'is_finished': True,
'success': False,
'error_message': error_message,
})
self._update_cache(context)
def _update_cache(self, context):
cache_key = self.get_face_cache_key(context['token'])
cache.set(cache_key, context, MFA_FACE_CONTEXT_CACHE_TTL)
def _handle_success(self, context, face_code):
context.update({
'is_finished': True,
'success': True,
'face_code': face_code
})
self._update_cache(context)
class MFAFaceContextApi(AuthMixin, RetrieveAPIView, CreateAPIView):
permission_classes = (AllowAny,)
face_token_session_key = MFA_FACE_SESSION_KEY
@staticmethod
def get_face_cache_key(token):
return f"{MFA_FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
def new_face_context(self):
token = uuid.uuid4().hex
cache_key = self.get_face_cache_key(token)
face_context = {
"token": token,
"is_finished": False
}
cache.set(cache_key, face_context, MFA_FACE_CONTEXT_CACHE_TTL)
self.request.session[self.face_token_session_key] = token
return token
def post(self, request, *args, **kwargs):
token = self.new_face_context()
return Response({'token': token})
def get(self, request, *args, **kwargs):
token = self.request.session.get('mfa_face_token')
cache_key = self.get_face_cache_key(token)
context = cache.get(cache_key)
if not context:
raise NotFound({'error': "Token does not exist or has expired."})
return Response({
"is_finished": context.get('is_finished', False),
"success": context.get('success', False),
"error_message": context.get("error_message", '')
})
# MFASelectAPi 原来的名字
class MFASendCodeApi(AuthMixin, CreateAPIView):
"""

12
apps/authentication/api/password.py

@ -1,5 +1,6 @@
import time
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponseRedirect
from django.shortcuts import reverse
@ -40,12 +41,15 @@ class UserResetPasswordSendCodeApi(CreateAPIView):
return user, None
@staticmethod
def safe_send_code(token, code, target, form_type, content):
def safe_send_code(token, code, target, form_type, content, user_info):
token_sent_key = '{}_send_at'.format(token)
token_send_at = cache.get(token_sent_key, 0)
if token_send_at:
raise IntervalTooShort(60)
SendAndVerifyCodeUtil(target, code, backend=form_type, **content).gen_and_send_async()
tooler = SendAndVerifyCodeUtil(
target, code, backend=form_type, user_info=user_info, **content
)
tooler.gen_and_send_async()
cache.set(token_sent_key, int(time.time()), 60)
def prepare_code_data(self, user_info, serializer):
@ -61,7 +65,7 @@ class UserResetPasswordSendCodeApi(CreateAPIView):
if not user:
raise ValueError(err)
code = random_string(6, lower=False, upper=False)
code = random_string(settings.SMS_CODE_LENGTH, lower=False, upper=False)
subject = '%s: %s' % (get_login_title(), _('Forgot password'))
context = {
'user': user, 'title': subject, 'code': code,
@ -82,7 +86,7 @@ class UserResetPasswordSendCodeApi(CreateAPIView):
code, target, form_type, content = self.prepare_code_data(user_info, serializer)
except ValueError as e:
return Response({'error': str(e)}, status=400)
self.safe_send_code(token, code, target, form_type, content)
self.safe_send_code(token, code, target, form_type, content, user_info)
return Response({'data': 'ok'}, status=200)

44
apps/authentication/backends/ldap.py

@ -3,8 +3,9 @@
import abc
import ldap
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
from django_auth_ldap.backend import _LDAPUser, LDAPBackend
from django_auth_ldap.backend import _LDAPUser, LDAPBackend, valid_cache_key
from django_auth_ldap.config import _LDAPConfig, LDAPSearch, LDAPSearchUnion
from users.utils import construct_user_email
@ -146,30 +147,53 @@ class LDAPHAAuthorizationBackend(JMSBaseAuthBackend, LDAPBaseBackend):
class LDAPUser(_LDAPUser):
def __init__(self, backend, username=None, user=None, request=None):
super().__init__(backend=backend, username=username, user=user, request=request)
config_prefix = "" if isinstance(self.backend, LDAPAuthorizationBackend) else "_ha"
self.user_dn_cache_key = valid_cache_key(
f"django_auth_ldap{config_prefix}.user_dn.{self._username}"
)
self.category = f"ldap{config_prefix}"
self.search_filter = getattr(settings, f"AUTH_LDAP{config_prefix.upper()}_SEARCH_FILTER", None)
self.search_ou = getattr(settings, f"AUTH_LDAP{config_prefix.upper()}_SEARCH_OU", None)
def _search_for_user_dn_from_ldap_util(self):
from settings.utils import LDAPServerUtil
util = LDAPServerUtil()
util = LDAPServerUtil(category=self.category)
user_dn = util.search_for_user_dn(self._username)
return user_dn
def _load_user_dn(self):
"""
Populates self._user_dn with the distinguished name of our user.
This will either construct the DN from a template in
AUTH_LDAP_USER_DN_TEMPLATE or connect to the server and search for it.
If we have to search, we'll cache the DN.
"""
if self._using_simple_bind_mode():
self._user_dn = self._construct_simple_user_dn()
else:
if self.settings.CACHE_TIMEOUT > 0:
self._user_dn = cache.get_or_set(
self.user_dn_cache_key, self._search_for_user_dn, self.settings.CACHE_TIMEOUT
)
else:
self._user_dn = self._search_for_user_dn()
def _search_for_user_dn(self):
"""
This method was overridden because the AUTH_LDAP_USER_SEARCH
configuration in the settings.py file
is configured with a `lambda` problem value
"""
if isinstance(self.backend, LDAPAuthorizationBackend):
search_filter = settings.AUTH_LDAP_SEARCH_FILTER
search_ou = settings.AUTH_LDAP_SEARCH_OU
else:
search_filter = settings.AUTH_LDAP_HA_SEARCH_FILTER
search_ou = settings.AUTH_LDAP_HA_SEARCH_OU
user_search_union = [
LDAPSearch(
USER_SEARCH, ldap.SCOPE_SUBTREE,
search_filter
self.search_filter
)
for USER_SEARCH in str(search_ou).split("|")
for USER_SEARCH in str(self.search_ou).split("|")
]
search = LDAPSearchUnion(*user_search_union)

1
apps/authentication/confirm/mfa.py

@ -22,5 +22,6 @@ class ConfirmMFA(BaseConfirm):
def authenticate(self, secret_key, mfa_type):
mfa_backend = self.user.get_mfa_backend_by_type(mfa_type)
mfa_backend.set_request(self.request)
ok, msg = mfa_backend.check_code(secret_key)
return ok, msg

8
apps/authentication/const.py

@ -2,7 +2,7 @@ from django.db.models import TextChoices
from authentication.confirm import CONFIRM_BACKENDS
from .confirm import ConfirmMFA, ConfirmPassword, ConfirmReLogin
from .mfa import MFAOtp, MFASms, MFARadius, MFACustom
from .mfa import MFAOtp, MFASms, MFARadius, MFAFace, MFACustom
RSA_PRIVATE_KEY = 'rsa_private_key'
RSA_PUBLIC_KEY = 'rsa_public_key'
@ -35,5 +35,11 @@ class ConfirmType(TextChoices):
class MFAType(TextChoices):
OTP = MFAOtp.name, MFAOtp.display_name
SMS = MFASms.name, MFASms.display_name
Face = MFAFace.name, MFAFace.display_name
Radius = MFARadius.name, MFARadius.display_name
Custom = MFACustom.name, MFACustom.display_name
MFA_FACE_CONTEXT_CACHE_KEY_PREFIX = "MFA_FACE_RECOGNITION_CONTEXT"
MFA_FACE_CONTEXT_CACHE_TTL = 60
MFA_FACE_SESSION_KEY = "mfa_face_token"

1
apps/authentication/mfa/__init__.py

@ -2,3 +2,4 @@ from .otp import MFAOtp, otp_failed_msg
from .sms import MFASms
from .radius import MFARadius
from .custom import MFACustom
from .face import MFAFace

4
apps/authentication/mfa/base.py

@ -12,10 +12,14 @@ class BaseMFA(abc.ABC):
因为首页登录时可能没法获取到一些状态
"""
self.user = user
self.request = None
def is_authenticated(self):
return self.user and self.user.is_authenticated
def set_request(self, request):
self.request = request
@property
@abc.abstractmethod
def name(self):

57
apps/authentication/mfa/face.py

@ -0,0 +1,57 @@
from django.core.cache import cache
from authentication.mfa.base import BaseMFA
from django.shortcuts import reverse
from django.utils.translation import gettext_lazy as _
from authentication.mixins import MFAFaceMixin
from common.const import LicenseEditionChoices
from settings.api import settings
class MFAFace(BaseMFA, MFAFaceMixin):
name = "face"
display_name = _('Face Recognition')
placeholder = 'Face Recognition'
def check_code(self, code):
assert self.is_authenticated()
try:
code = self.get_face_code()
if not self.user.check_face(code):
return False, _('Facial comparison failed')
except Exception as e:
return False, "{}:{}".format(_('Facial comparison failed'), str(e))
return True, ''
def is_active(self):
if not self.is_authenticated():
return True
return bool(self.user.face_vector)
@staticmethod
def global_enabled():
return settings.XPACK_LICENSE_IS_VALID \
and LicenseEditionChoices.ULTIMATE == \
LicenseEditionChoices.from_key(settings.XPACK_LICENSE_EDITION) \
and settings.FACE_RECOGNITION_ENABLED
def get_enable_url(self) -> str:
return reverse('authentication:user-face-enable')
def get_disable_url(self) -> str:
return reverse('authentication:user-face-disable')
def disable(self):
assert self.is_authenticated()
self.user.face_vector = ''
self.user.save(update_fields=['face_vector'])
def can_disable(self) -> bool:
return True
@staticmethod
def help_text_of_enable():
return _("Frontal Face Recognition")

2
apps/authentication/mfa/radius.py

@ -12,7 +12,7 @@ class MFARadius(BaseMFA):
display_name = 'Radius'
placeholder = _("Radius verification code")
def check_code(self, code):
def check_code(self, code=None):
assert self.is_authenticated()
backend = RadiusBackend()
username = self.user.username

10
apps/authentication/mfa/sms.py

@ -2,6 +2,7 @@ from django.conf import settings
from django.utils.translation import gettext_lazy as _
from common.utils.verify_code import SendAndVerifyCodeUtil
from users.serializers import SmsUserSerializer
from .base import BaseMFA
sms_failed_msg = _("SMS verify code invalid")
@ -14,8 +15,13 @@ class MFASms(BaseMFA):
def __init__(self, user):
super().__init__(user)
phone = user.phone if self.is_authenticated() else ''
self.sms = SendAndVerifyCodeUtil(phone, backend=self.name)
phone, user_info = '', None
if self.is_authenticated():
phone = user.phone
user_info = SmsUserSerializer(user).data
self.sms = SendAndVerifyCodeUtil(
phone, backend=self.name, user_info=user_info
)
def check_code(self, code):
assert self.is_authenticated()

2
apps/authentication/middleware.py

@ -35,7 +35,7 @@ class MFAMiddleware:
# 这个是 mfa 登录页需要的请求, 也得放出来, 用户其实已经在 CAS/OIDC 中完成登录了
white_urls = [
'login/mfa', 'mfa/select', 'jsi18n/', '/static/',
'login/mfa', 'mfa/select', 'mfa/face','jsi18n/', '/static/',
'/profile/otp', '/logout/',
]
for url in white_urls:

49
apps/authentication/mixins.py

@ -199,6 +199,53 @@ class AuthPreCheckMixin:
self.raise_credential_error(errors.reason_user_not_exist)
class MFAFaceMixin:
request = None
def get_face_recognition_token(self):
from authentication.const import MFA_FACE_SESSION_KEY
token = self.request.session.get(MFA_FACE_SESSION_KEY)
if not token:
raise ValueError("Face recognition token is missing from the session.")
return token
@staticmethod
def get_face_cache_key(token):
from authentication.const import MFA_FACE_CONTEXT_CACHE_KEY_PREFIX
return f"{MFA_FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
def get_face_recognition_context(self):
token = self.get_face_recognition_token()
cache_key = self.get_face_cache_key(token)
context = cache.get(cache_key)
if not context:
raise ValueError(f"Face recognition context does not exist for token: {token}")
return context
@staticmethod
def is_context_finished(context):
return context.get('is_finished', False)
@staticmethod
def is_context_success(context):
return context.get('success', False)
def get_face_code(self):
context = self.get_face_recognition_context()
if not self.is_context_finished(context):
raise RuntimeError("Face recognition is not yet completed.")
if not self.is_context_success(context):
msg = context.get('error_message', '')
raise RuntimeError(msg)
face_code = context.get('face_code')
if not face_code:
raise ValueError("Face code is missing from the context.")
return face_code
class MFAMixin:
request: Request
get_user_from_session: Callable
@ -263,7 +310,6 @@ class MFAMixin:
user = user if user else self.get_user_from_session()
if not user.mfa_enabled:
return
# 监测 MFA 是不是屏蔽了
ip = self.get_request_ip()
self.check_mfa_is_block(user.username, ip)
@ -276,6 +322,7 @@ class MFAMixin:
elif not mfa_backend.is_active():
msg = backend_error.format(mfa_backend.display_name)
else:
mfa_backend.set_request(self.request)
ok, msg = mfa_backend.check_code(code)
if ok:

14
apps/authentication/serializers/password_mfa.py

@ -8,6 +8,7 @@ from common.serializers.fields import EncryptedField
__all__ = [
'MFAChallengeSerializer', 'MFASelectTypeSerializer',
'PasswordVerifySerializer', 'ResetPasswordCodeSerializer',
'MFAFaceCallbackSerializer'
]
@ -51,3 +52,16 @@ class MFAChallengeSerializer(serializers.Serializer):
def update(self, instance, validated_data):
pass
class MFAFaceCallbackSerializer(serializers.Serializer):
token = serializers.CharField(required=True, allow_blank=False)
success = serializers.BooleanField(required=True, allow_null=False)
error_message = serializers.CharField(required=False, allow_null=True, allow_blank=True)
face_code = serializers.CharField(required=False, allow_null=True, allow_blank=True)
def update(self, instance, validated_data):
pass
def create(self, validated_data):
pass

92
apps/authentication/templates/authentication/face_capture.html

@ -0,0 +1,92 @@
{% extends '_base_only_content.html' %}
{% load i18n %}
{% load static %}
{% block content %}
{% if 'code' in form.errors %}
<div class="alert alert-danger" id="messages">
<p class="red-fonts">{{ form.code.errors.as_text }}</p>
</div>
{% endif %}
<div id="retry_container" style="text-align: center; margin-top: 20px; display: none;">
<button id="retry_button" class="btn btn-primary">{% trans 'Retry' %}</button>
</div>
<form class="m-t" role="form" method="post" action="" style="display: none">
{% csrf_token %}
<button id="submit_button" type="submit" style="display: none"></button>
</form>
<div id="iframe_container"
style="display: none; justify-content: center; align-items: center; height: 520px; width: 100%;">
<iframe
title="face capture"
id="face_capture_iframe"
allow="camera"
sandbox="allow-scripts allow-same-origin"
style="width: 100%; height:100%;border: none;">
</iframe>
</div>
<script>
$(document).ready(function () {
const apiUrl = "{% url 'api-auth:mfa-face-context' %}";
const faceCaptureUrl = "/facelive/capture";
let token;
function createFaceCaptureToken() {
const csrf = getCookie('jms_csrftoken');
$.ajax({
url: apiUrl,
method: 'POST',
headers: {
'X-CSRFToken': csrf
},
success: function (data) {
token = data.token;
$('#iframe_container').show();
$('#face_capture_iframe').attr('src', `${faceCaptureUrl}?token=${token}`);
startCheckingStatus();
},
error: function (error) {
$('#retry_container').show();
}
});
}
function startCheckingStatus() {
const interval = 1000;
const timer = setInterval(function () {
$.ajax({
url: `${apiUrl}?token=${token}`,
method: 'GET',
success: function (data) {
if (data.is_finished) {
clearInterval(timer);
$('#submit_button').click();
}
},
error: function (error) {
console.error('API request failed:', error);
}
});
}, interval);
}
const active = "{{ active }}";
if (active) {
createFaceCaptureToken();
} else {
$('#retry_container').show();
}
$('#retry_button').on('click', function () {
window.location.href = "{% url 'authentication:login-face-capture' %}";
});
});
</script>
{% endblock %}

2
apps/authentication/urls/api_urls.py

@ -33,6 +33,8 @@ urlpatterns = [
path('mfa/challenge/', api.MFAChallengeVerifyApi.as_view(), name='mfa-challenge'),
path('mfa/select/', api.MFASendCodeApi.as_view(), name='mfa-select'),
path('mfa/send-code/', api.MFASendCodeApi.as_view(), name='mfa-send-code'),
path('mfa/face/callback/', api.MFAFaceCallbackApi.as_view(), name='mfa-face-callback'),
path('mfa/face/context/', api.MFAFaceContextApi.as_view(), name='mfa-face-context'),
path('password/reset-code/', api.UserResetPasswordSendCodeApi.as_view(), name='reset-password-code'),
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),

3
apps/authentication/urls/view_urls.py

@ -14,6 +14,7 @@ urlpatterns = [
path('login/', non_atomic_requests(views.UserLoginView.as_view()), name='login'),
path('login/mfa/', views.UserLoginMFAView.as_view(), name='login-mfa'),
path('login/wait-confirm/', views.UserLoginWaitConfirmView.as_view(), name='login-wait-confirm'),
path('login/mfa/face/capture/', views.UserLoginMFAFaceView.as_view(), name='login-face-capture'),
path('login/guard/', views.UserLoginGuardView.as_view(), name='login-guard'),
path('logout/', views.UserLogoutView.as_view(), name='logout'),
@ -73,6 +74,8 @@ urlpatterns = [
path('profile/otp/disable/', users_view.UserOtpDisableView.as_view(),
name='user-otp-disable'),
path('profile/face/enable/', users_view.UserFaceEnableView.as_view(), name='user-face-enable'),
path('profile/face/disable/', users_view.UserFaceDisableView.as_view(), name='user-face-disable'),
# other authentication protocol
path('cas/', include(('authentication.backends.cas.urls', 'authentication'), namespace='cas')),
path('openid/', include(('authentication.backends.oidc.urls', 'authentication'), namespace='openid')),

2
apps/authentication/views/login.py

@ -24,8 +24,8 @@ from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic.base import TemplateView, RedirectView
from django.views.generic.edit import FormView
from common.utils import FlashMessageUtil, static_or_direct, safe_next_url
from common.const import Language
from common.utils import FlashMessageUtil, static_or_direct, safe_next_url
from users.utils import (
redirect_user_first_login_or_index
)

18
apps/authentication/views/mfa.py

@ -3,14 +3,16 @@
from __future__ import unicode_literals
from django.views.generic.edit import FormView
from django.shortcuts import redirect
from django.shortcuts import redirect, reverse
from common.utils import get_logger
from users.views import UserFaceCaptureView
from .. import forms, errors, mixins
from .utils import redirect_to_guard_view
from ..const import MFAType
logger = get_logger(__name__)
__all__ = ['UserLoginMFAView']
__all__ = ['UserLoginMFAView', 'UserLoginMFAFaceView']
class UserLoginMFAView(mixins.AuthMixin, FormView):
@ -32,10 +34,16 @@ class UserLoginMFAView(mixins.AuthMixin, FormView):
return super().get(*args, **kwargs)
def form_valid(self, form):
from users.utils import MFABlockUtils
code = form.cleaned_data.get('code')
mfa_type = form.cleaned_data.get('mfa_type')
if mfa_type == MFAType.Face:
return redirect(reverse('authentication:login-face-capture'))
return self.do_mfa_check(form, code, mfa_type)
def do_mfa_check(self, form, code, mfa_type):
from users.utils import MFABlockUtils
try:
self._do_check_user_mfa(code, mfa_type)
user, ip = self.get_user_from_session(), self.get_request_ip()
@ -58,3 +66,7 @@ class UserLoginMFAView(mixins.AuthMixin, FormView):
kwargs.update(mfa_context)
return kwargs
class UserLoginMFAFaceView(UserFaceCaptureView, UserLoginMFAView):
def form_valid(self, form):
return self.do_mfa_check(form, self.code, self.mfa_type)

18
apps/authentication/views/wecom.py

@ -13,20 +13,17 @@ from authentication.const import ConfirmType
from authentication.mixins import AuthMixin
from authentication.permissions import UserConfirmation
from common.sdk.im.wecom import URL
from common.sdk.im.wecom import WeCom
from common.sdk.im.wecom import WeCom, wecom_tool
from common.utils import get_logger
from common.utils.common import get_request_ip
from common.utils.django import reverse, get_object_or_none, safe_next_url
from common.utils.random import random_string
from common.views.mixins import UserConfirmRequiredExceptionMixin, PermissionsMixin
from users.models import User
from users.views import UserVerifyPasswordView
from .base import BaseLoginCallbackView, BaseBindCallbackView
from .mixins import METAMixin, FlashMessageMixin
logger = get_logger(__file__)
WECOM_STATE_SESSION_KEY = '_wecom_state'
logger = get_logger(__file__)
class WeComBaseMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashMessageMixin, View):
@ -45,7 +42,7 @@ class WeComBaseMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashM
)
def verify_state(self):
return self.verify_state_with_session_key(WECOM_STATE_SESSION_KEY)
return wecom_tool.check_state(self.request.GET.get('state'), self.request)
def get_already_bound_response(self, redirect_url):
msg = _('WeCom is already bound')
@ -56,13 +53,10 @@ class WeComBaseMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashM
class WeComQRMixin(WeComBaseMixin, View):
def get_qr_url(self, redirect_uri):
state = random_string(16)
self.request.session[WECOM_STATE_SESSION_KEY] = state
params = {
'appid': settings.WECOM_CORPID,
'agentid': settings.WECOM_AGENTID,
'state': state,
'state': wecom_tool.gen_state(request=self.request),
'redirect_uri': redirect_uri,
}
url = URL.QR_CONNECT + '?' + urlencode(params)
@ -74,13 +68,11 @@ class WeComOAuthMixin(WeComBaseMixin, View):
def get_oauth_url(self, redirect_uri):
if not settings.AUTH_WECOM:
return reverse('authentication:login')
state = random_string(16)
self.request.session[WECOM_STATE_SESSION_KEY] = state
params = {
'appid': settings.WECOM_CORPID,
'agentid': settings.WECOM_AGENTID,
'state': state,
'state': wecom_tool.gen_state(request=self.request),
'redirect_uri': redirect_uri,
'response_type': 'code',
'scope': 'snsapi_base',

29
apps/common/const/choices.py

@ -76,3 +76,32 @@ class Language(models.TextChoices):
COUNTRY_CALLING_CODES = get_country_phone_choices()
class LicenseEditionChoices(models.TextChoices):
COMMUNITY = 'community', _('Community edition')
BASIC = 'basic', _('Basic edition')
STANDARD = 'standard', _('Standard edition')
PROFESSIONAL = 'professional', _('Professional edition')
ULTIMATE = 'ultimate', _('Ultimate edition')
@staticmethod
def from_key(key: str):
for choice in LicenseEditionChoices:
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:
return LicenseEditionChoices.BASIC
elif count <= 500:
return LicenseEditionChoices.STANDARD
elif count < 5000:
return LicenseEditionChoices.PROFESSIONAL
elif count >= 5000:
return LicenseEditionChoices.ULTIMATE
else:
return LicenseEditionChoices.COMMUNITY

2
apps/common/drf/parsers/base.py

@ -126,7 +126,7 @@ class BaseFileParser(BaseParser):
value = self.id_name_to_obj(value)
elif isinstance(field, LabeledChoiceField):
value = self.id_name_to_obj(value)
if isinstance(value, dict) and value.get('pk'):
if isinstance(value, dict) and 'pk' in value:
value = value.get('pk')
elif isinstance(field, serializers.ListSerializer):
value = [self.parse_value(field.child, v) for v in value]

1
apps/common/management/commands/check_api.py

@ -73,6 +73,7 @@ known_unauth_urls = [
"/api/v1/authentication/password/reset-code/",
"/api/v1/authentication/login-confirm-ticket/status/",
"/api/v1/authentication/mfa/select/",
"/api/v1/authentication/mfa/face/context/",
"/api/v1/authentication/mfa/send-code/",
"/api/v1/authentication/sso/login/",
"/api/v1/authentication/user-session/",

6
apps/common/sdk/im/utils.py

@ -16,12 +16,6 @@ def digest(corp_id, corp_secret):
return dist
def update_values(default: dict, others: dict):
for key in default.keys():
if key in others:
default[key] = others[key]
def set_default(data: dict, default: dict):
for key in default.keys():
if key not in data:

74
apps/common/sdk/im/wecom/__init__.py

@ -1,12 +1,14 @@
from typing import Iterable, AnyStr
from urllib.parse import urlencode
from django.conf import settings
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import APIException
from common.sdk.im.mixin import RequestMixin, BaseRequest
from common.sdk.im.utils import digest, update_values
from common.utils.common import get_logger
from common.sdk.im.utils import digest
from common.utils import reverse, random_string, get_logger, lazyproperty
from users.utils import construct_user_email, flatten_dict, map_attributes
logger = get_logger(__name__)
@ -107,15 +109,6 @@ class WeCom(RequestMixin):
对于业务代码只需要关心由 用户id 消息不对 导致的错误其他错误不予理会
"""
users = tuple(users)
extra_params = {
"safe": 0,
"enable_id_trans": 0,
"enable_duplicate_check": 0,
"duplicate_check_interval": 1800
}
update_values(extra_params, kwargs)
body = {
"touser": '|'.join(users),
"msgtype": "text",
@ -123,7 +116,7 @@ class WeCom(RequestMixin):
"text": {
"content": msg
},
**extra_params
**kwargs
}
if markdown:
body['msgtype'] = 'markdown'
@ -144,15 +137,15 @@ class WeCom(RequestMixin):
if 'invaliduser' not in data:
return ()
invaliduser = data['invaliduser']
if not invaliduser:
invalid_user = data['invaliduser']
if not invalid_user:
return ()
if isinstance(invaliduser, str):
logger.error(f'WeCom send text 200, but invaliduser is not str: invaliduser={invaliduser}')
if isinstance(invalid_user, str):
logger.error(f'WeCom send text 200, but invaliduser is not str: invaliduser={invalid_user}')
raise WeComError
invalid_users = invaliduser.split('|')
invalid_users = invalid_user.split('|')
return invalid_users
def get_user_id_by_code(self, code):
@ -167,13 +160,12 @@ class WeCom(RequestMixin):
self._requests.check_errcode_is_0(data)
USER_ID = 'UserId'
OPEN_ID = 'OpenId'
if USER_ID in data:
return data[USER_ID], USER_ID
elif OPEN_ID in data:
return data[OPEN_ID], OPEN_ID
user_id = 'UserId'
open_id = 'OpenId'
if user_id in data:
return data[user_id], user_id
elif open_id in data:
return data[open_id], open_id
else:
logger.error(f'WeCom response 200 but get field from json error: fields=UserId|OpenId')
raise WeComError
@ -195,3 +187,37 @@ class WeCom(RequestMixin):
default_detail = self.default_user_detail(data, user_id)
detail = map_attributes(default_detail, info, self.attributes)
return detail
class WeComTool(object):
WECOM_STATE_SESSION_KEY = '_wecom_state'
WECOM_STATE_VALUE = 'wecom'
@lazyproperty
def qr_cb_url(self):
return reverse('authentication:wecom-qr-login-callback', external=True)
def gen_state(self, request=None):
state = random_string(16)
if not request:
cache.set(state, self.WECOM_STATE_VALUE, timeout=60 * 60 * 24)
else:
request.session[self.WECOM_STATE_SESSION_KEY] = state
return state
def check_state(self, state, request=None):
return cache.get(state) == self.WECOM_STATE_VALUE or \
request.session[self.WECOM_STATE_SESSION_KEY] == state
def wrap_redirect_url(self, next_url):
params = {
'appid': settings.WECOM_CORPID,
'agentid': settings.WECOM_AGENTID,
'state': self.gen_state(),
'redirect_uri': f'{self.qr_cb_url}?next={next_url}',
'response_type': 'code', 'scope': 'snsapi_base',
}
return URL.OAUTH_CONNECT + '?' + urlencode(params) + '#wechat_redirect'
wecom_tool = WeComTool()

7
apps/common/sdk/sms/endpoint.py

@ -43,7 +43,7 @@ class SMS:
**kwargs
)
def send_verify_code(self, phone_number, code):
def send_verify_code(self, phone_number, code, **kwargs):
prefix = getattr(self.client, 'SIGN_AND_TMPL_SETTING_FIELD_PREFIX', '')
sign_name = getattr(settings, f'{prefix}_VERIFY_SIGN_NAME', None)
template_code = getattr(settings, f'{prefix}_VERIFY_TEMPLATE_CODE', None)
@ -53,4 +53,7 @@ class SMS:
code='verify_code_sign_tmpl_invalid',
detail=_('SMS verification code signature or template invalid')
)
return self.send_sms([phone_number], sign_name, template_code, OrderedDict(code=code))
return self.send_sms(
[phone_number], sign_name, template_code,
OrderedDict(code=code), **kwargs
)

2
apps/common/storage/base.py

@ -1,9 +1,9 @@
import os
import jms_storage
from django.conf import settings
from django.core.files.storage import default_storage
from common.storage import jms_storage
from common.utils import get_logger, make_dirs
from terminal.models import ReplayStorage

41
apps/common/storage/jms_storage/__init__.py

@ -0,0 +1,41 @@
#!/usr/bin/env python
# coding: utf-8
# Copyright (c) 2018
#
__version__ = '0.0.59'
from .ftp import FTPStorage
from .oss import OSSStorage
from .obs import OBSStorage
from .s3 import S3Storage
from .azure import AzureStorage
from .ceph import CEPHStorage
from .jms import JMSReplayStorage, JMSCommandStorage
from .multi import MultiObjectStorage
from .sftp import SFTPStorage
def get_object_storage(config):
if config.get("TYPE") in ["s3", "ceph", "swift", "cos"]:
return S3Storage(config)
elif config.get("TYPE") == "oss":
return OSSStorage(config)
elif config.get("TYPE") == "server":
return JMSReplayStorage(config)
elif config.get("TYPE") == "azure":
return AzureStorage(config)
elif config.get("TYPE") == "ceph":
return CEPHStorage(config)
elif config.get("TYPE") == "ftp":
return FTPStorage(config)
elif config.get("TYPE") == "obs":
return OBSStorage(config)
elif config.get("TYPE") == "sftp":
return SFTPStorage(config)
else:
return JMSReplayStorage(config)
def get_multi_object_storage(configs):
return MultiObjectStorage(configs)

61
apps/common/storage/jms_storage/azure.py

@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
#
import os
from azure.storage.blob import BlobServiceClient
from .base import ObjectStorage
class AzureStorage(ObjectStorage):
def __init__(self, config):
self.account_name = config.get("ACCOUNT_NAME", None)
self.account_key = config.get("ACCOUNT_KEY", None)
self.container_name = config.get("CONTAINER_NAME", None)
self.endpoint_suffix = config.get("ENDPOINT_SUFFIX", 'core.chinacloudapi.cn')
if self.account_name and self.account_key:
self.service_client = BlobServiceClient(
account_url=f'https://{self.account_name}.blob.{self.endpoint_suffix}',
credential={'account_name': self.account_name, 'account_key': self.account_key}
)
self.client = self.service_client.get_container_client(self.container_name)
else:
self.client = None
def upload(self, src, target):
try:
self.client.upload_blob(target, src)
return True, None
except Exception as e:
return False, e
def download(self, src, target):
try:
blob_data = self.client.download_blob(blob=src)
os.makedirs(os.path.dirname(target), 0o755, exist_ok=True)
with open(target, 'wb') as writer:
writer.write(blob_data.readall())
return True, None
except Exception as e:
return False, e
def delete(self, path):
try:
self.client.delete_blob(path)
return True, False
except Exception as e:
return False, e
def exists(self, path):
resp = self.client.list_blobs(name_starts_with=path)
return len(list(resp)) != 0
def list_buckets(self):
return list(self.service_client.list_containers())
@property
def type(self):
return 'azure'

51
apps/common/storage/jms_storage/base.py

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
#
import abc
class ObjectStorage(metaclass=abc.ABCMeta):
@abc.abstractmethod
def upload(self, src, target):
return None, None
@abc.abstractmethod
def download(self, src, target):
pass
@abc.abstractmethod
def delete(self, path):
pass
@abc.abstractmethod
def exists(self, path):
pass
def is_valid(self, src, target):
ok, msg = self.upload(src=src, target=target)
if not ok:
return False
self.delete(path=target)
return True
class LogStorage(metaclass=abc.ABCMeta):
@abc.abstractmethod
def save(self, command):
pass
@abc.abstractmethod
def bulk_save(self, command_set, raise_on_error=True):
pass
@abc.abstractmethod
def filter(self, date_from=None, date_to=None,
user=None, asset=None, account=None,
input=None, session=None):
pass
@abc.abstractmethod
def count(self, date_from=None, date_to=None,
user=None, asset=None, account=None,
input=None, session=None):
pass

68
apps/common/storage/jms_storage/ceph.py

@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
#
import os
import boto
import boto.s3.connection
from .base import ObjectStorage
class CEPHStorage(ObjectStorage):
def __init__(self, config):
self.bucket = config.get("BUCKET", None)
self.region = config.get("REGION", None)
self.access_key = config.get("ACCESS_KEY", None)
self.secret_key = config.get("SECRET_KEY", None)
self.hostname = config.get("HOSTNAME", None)
self.port = config.get("PORT", 7480)
if self.hostname and self.access_key and self.secret_key:
self.conn = boto.connect_s3(
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
host=self.hostname,
port=self.port,
is_secure=False,
calling_format=boto.s3.connection.OrdinaryCallingFormat(),
)
try:
self.client = self.conn.get_bucket(bucket_name=self.bucket)
except Exception:
self.client = None
def upload(self, src, target):
try:
key = self.client.new_key(target)
key.set_contents_from_filename(src)
return True, None
except Exception as e:
return False, e
def download(self, src, target):
try:
os.makedirs(os.path.dirname(target), 0o755, exist_ok=True)
key = self.client.get_key(src)
key.get_contents_to_filename(target)
return True, None
except Exception as e:
return False, e
def delete(self, path):
try:
self.client.delete_key(path)
return True, None
except Exception as e:
return False, e
def exists(self, path):
try:
return self.client.get_key(path)
except Exception:
return False
@property
def type(self):
return 'ceph'

116
apps/common/storage/jms_storage/ftp.py

@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
#
import os
from ftplib import FTP, error_perm
from .base import ObjectStorage
class FTPStorage(ObjectStorage):
def __init__(self, config):
self.host = config.get("HOST", None)
self.port = int(config.get("PORT", 21))
self.username = config.get("USERNAME", None)
self.password = config.get("PASSWORD", None)
self.pasv = bool(config.get("PASV", False))
self.dir = config.get("DIR", "replay")
self.client = FTP()
self.client.encoding = 'utf-8'
self.client.set_pasv(self.pasv)
self.pwd = '.'
self.connect()
def connect(self, timeout=-999, source_address=None):
self.client.connect(self.host, self.port, timeout, source_address)
self.client.login(self.username, self.password)
if not self.check_dir_exist(self.dir):
self.mkdir(self.dir)
self.client.cwd(self.dir)
self.pwd = self.client.pwd()
def confirm_connected(self):
try:
self.client.pwd()
except Exception:
self.connect()
def upload(self, src, target):
self.confirm_connected()
target_dir = os.path.dirname(target)
exist = self.check_dir_exist(target_dir)
if not exist:
ok = self.mkdir(target_dir)
if not ok:
raise PermissionError('Dir create error: %s' % target)
try:
with open(src, 'rb') as f:
self.client.storbinary('STOR '+target, f)
return True, None
except Exception as e:
return False, e
def download(self, src, target):
self.confirm_connected()
try:
os.makedirs(os.path.dirname(target), 0o755, exist_ok=True)
with open(target, 'wb') as f:
self.client.retrbinary('RETR ' + src, f.write)
return True, None
except Exception as e:
return False, e
def delete(self, path):
self.confirm_connected()
if not self.exists(path):
raise FileNotFoundError('File not exist error(%s)' % path)
try:
self.client.delete(path)
return True, None
except Exception as e:
return False, e
def check_dir_exist(self, d):
pwd = self.client.pwd()
try:
self.client.cwd(d)
self.client.cwd(pwd)
return True
except error_perm:
return False
def mkdir(self, dirs):
self.confirm_connected()
# 创建多级目录,ftplib不支持一次创建多级目录
dir_list = dirs.split('/')
pwd = self.client.pwd()
try:
for d in dir_list:
if not d or d in ['.']:
continue
# 尝试切换目录
try:
self.client.cwd(d)
continue
except:
pass
# 切换失败创建这个目录,再切换
try:
self.client.mkd(d)
self.client.cwd(d)
except:
return False
return True
finally:
self.client.cwd(pwd)
def exists(self, target):
self.confirm_connected()
try:
self.client.size(target)
return True
except:
return False
def close(self):
self.client.close()

50
apps/common/storage/jms_storage/jms.py

@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
#
import os
from .base import ObjectStorage, LogStorage
class JMSReplayStorage(ObjectStorage):
def __init__(self, config):
self.client = config.get("SERVICE")
def upload(self, src, target):
session_id = os.path.basename(target).split('.')[0]
ok = self.client.push_session_replay(src, session_id)
return ok, None
def delete(self, path):
return False, Exception("Not support not")
def exists(self, path):
return False
def download(self, src, target):
return False, Exception("Not support not")
@property
def type(self):
return 'jms'
class JMSCommandStorage(LogStorage):
def __init__(self, config):
self.client = config.get("SERVICE")
if not self.client:
raise Exception("Not found app service")
def save(self, command):
return self.client.push_session_command([command])
def bulk_save(self, command_set, raise_on_error=True):
return self.client.push_session_command(command_set)
def filter(self, date_from=None, date_to=None,
user=None, asset=None, account=None,
input=None, session=None):
pass
def count(self, date_from=None, date_to=None,
user=None, asset=None, account=None,
input=None, session=None):
pass

77
apps/common/storage/jms_storage/multi.py

@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
#
from .base import ObjectStorage, LogStorage
class MultiObjectStorage(ObjectStorage):
def __init__(self, configs):
self.configs = configs
self.storage_list = []
self.init_storage_list()
def init_storage_list(self):
from . import get_object_storage
if isinstance(self.configs, dict):
configs = self.configs.values()
else:
configs = self.configs
for config in configs:
try:
storage = get_object_storage(config)
self.storage_list.append(storage)
except Exception:
pass
def upload(self, src, target):
success = []
msg = []
for storage in self.storage_list:
ok, err = storage.upload(src, target)
success.append(ok)
msg.append(err)
return success, msg
def download(self, src, target):
success = False
msg = None
for storage in self.storage_list:
try:
if not storage.exists(src):
continue
ok, msg = storage.download(src, target)
if ok:
success = True
msg = ''
break
except:
pass
return success, msg
def delete(self, path):
success = True
msg = None
for storage in self.storage_list:
try:
if storage.exists(path):
ok, msg = storage.delete(path)
if not ok:
success = False
except:
pass
return success, msg
def exists(self, path):
for storage in self.storage_list:
try:
if storage.exists(path):
return True
except:
pass
return False

70
apps/common/storage/jms_storage/obs.py

@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
#
import os
from obs.client import ObsClient
from .base import ObjectStorage
class OBSStorage(ObjectStorage):
def __init__(self, config):
self.endpoint = config.get("ENDPOINT", None)
self.bucket = config.get("BUCKET", None)
self.access_key = config.get("ACCESS_KEY", None)
self.secret_key = config.get("SECRET_KEY", None)
if self.access_key and self.secret_key and self.endpoint:
proxy_host = os.getenv("proxy_host")
proxy_port = os.getenv("proxy_port")
proxy_username = os.getenv("proxy_username")
proxy_password = os.getenv("proxy_password")
self.obsClient = ObsClient(access_key_id=self.access_key, secret_access_key=self.secret_key, server=self.endpoint, proxy_host=proxy_host, proxy_port=proxy_port, proxy_username=proxy_username, proxy_password=proxy_password)
else:
self.obsClient = None
def upload(self, src, target):
try:
resp = self.obsClient.putFile(self.bucket, target, src)
if resp.status < 300:
return True, None
else:
return False, resp.reason
except Exception as e:
return False, e
def exists(self, path):
resp = self.obsClient.getObjectMetadata(self.bucket, path)
if resp.status < 300:
return True
return False
def delete(self, path):
try:
resp = self.obsClient.deleteObject(self.bucket, path)
if resp.status < 300:
return True, None
else:
return False, resp.reason
except Exception as e:
return False, e
def download(self, src, target):
try:
os.makedirs(os.path.dirname(target), 0o755, exist_ok=True)
resp = self.obsClient.getObject(self.bucket, src, target)
if resp.status < 300:
return True, None
else:
return False, resp.reason
except Exception as e:
return False, e
def list_buckets(self):
resp = self.obsClient.listBuckets()
if resp.status < 300:
return [b.name for b in resp.body.buckets]
else:
raise RuntimeError(resp.status, str(resp.reason))
@property
def type(self):
return 'obs'

72
apps/common/storage/jms_storage/oss.py

@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
#
import os
import time
import oss2
from .base import ObjectStorage
class OSSStorage(ObjectStorage):
def __init__(self, config):
self.endpoint = config.get("ENDPOINT", None)
self.bucket = config.get("BUCKET", None)
self.access_key = config.get("ACCESS_KEY", None)
self.secret_key = config.get("SECRET_KEY", None)
if self.access_key and self.secret_key:
self.auth = oss2.Auth(self.access_key, self.secret_key)
else:
self.auth = None
if self.auth and self.endpoint and self.bucket:
self.client = oss2.Bucket(self.auth, self.endpoint, self.bucket)
else:
self.client = None
def upload(self, src, target):
try:
self.client.put_object_from_file(target, src)
return True, None
except Exception as e:
return False, e
def exists(self, path):
try:
return self.client.object_exists(path)
except Exception as e:
return False
def delete(self, path):
try:
self.client.delete_object(path)
return True, None
except Exception as e:
return False, e
def restore(self, path):
meta = self.client.head_object(path)
if meta.resp.headers['x-oss-storage-class'] == oss2.BUCKET_STORAGE_CLASS_ARCHIVE:
self.client.restore_object(path)
while True:
meta = self.client.head_object(path)
if meta.resp.headers['x-oss-restore'] == 'ongoing-request="true"':
time.sleep(5)
else:
break
def download(self, src, target):
try:
os.makedirs(os.path.dirname(target), 0o755, exist_ok=True)
self.restore(src)
self.client.get_object_to_file(src, target)
return True, None
except Exception as e:
return False, e
def list_buckets(self):
service = oss2.Service(self.auth,self.endpoint)
return ([b.name for b in oss2.BucketIterator(service)])
@property
def type(self):
return 'oss'

74
apps/common/storage/jms_storage/s3.py

@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
#
import boto3
import os
from .base import ObjectStorage
class S3Storage(ObjectStorage):
def __init__(self, config):
self.bucket = config.get("BUCKET", "jumpserver")
self.region = config.get("REGION", None)
self.access_key = config.get("ACCESS_KEY", None)
self.secret_key = config.get("SECRET_KEY", None)
self.endpoint = config.get("ENDPOINT", None)
try:
self.client = boto3.client(
's3', region_name=self.region,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
endpoint_url=self.endpoint
)
except ValueError:
pass
def upload(self, src, target):
try:
self.client.upload_file(Filename=src, Bucket=self.bucket, Key=target)
return True, None
except Exception as e:
return False, e
def exists(self, path):
try:
self.client.head_object(Bucket=self.bucket, Key=path)
return True
except Exception as e:
return False
def download(self, src, target):
try:
os.makedirs(os.path.dirname(target), 0o755, exist_ok=True)
self.client.download_file(self.bucket, src, target)
return True, None
except Exception as e:
return False, e
def delete(self, path):
try:
self.client.delete_object(Bucket=self.bucket, Key=path)
return True, None
except Exception as e:
return False, e
def generate_presigned_url(self, path, expire=3600):
try:
return self.client.generate_presigned_url(
ClientMethod='get_object',
Params={'Bucket': self.bucket, 'Key': path},
ExpiresIn=expire,
HttpMethod='GET'), None
except Exception as e:
return False, e
def list_buckets(self):
response = self.client.list_buckets()
buckets = response.get('Buckets', [])
result = [b['Name'] for b in buckets if b.get('Name')]
return result
@property
def type(self):
return 's3'

109
apps/common/storage/jms_storage/sftp.py

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
import io
import os
import paramiko
from .base import ObjectStorage
class SFTPStorage(ObjectStorage):
def __init__(self, config):
self.sftp = None
self.sftp_host = config.get('SFTP_HOST', None)
self.sftp_port = int(config.get('SFTP_PORT', 22))
self.sftp_username = config.get('SFTP_USERNAME', '')
self.sftp_secret_type = config.get('STP_SECRET_TYPE', 'password')
self.sftp_password = config.get('SFTP_PASSWORD', '')
self.sftp_private_key = config.get('STP_PRIVATE_KEY', '')
self.sftp_passphrase = config.get('STP_PASSPHRASE', '')
self.sftp_root_path = config.get('SFTP_ROOT_PATH', '/tmp')
self.ssh = paramiko.SSHClient()
self.connect()
def connect(self):
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if self.sftp_secret_type == 'password':
self.ssh.connect(self.sftp_host, self.sftp_port, self.sftp_username, self.sftp_password)
elif self.sftp_secret_type == 'ssh_key':
pkey = paramiko.RSAKey.from_private_key(io.StringIO(self.sftp_private_key))
self.ssh.connect(self.sftp_host, self.sftp_port, self.sftp_username, pkey=pkey,
passphrase=self.sftp_passphrase)
self.sftp = self.ssh.open_sftp()
def confirm_connected(self):
try:
self.sftp.getcwd()
except Exception as e:
self.connect()
def upload(self, src, target):
local_file = src
remote_file = os.path.join(self.sftp_root_path, target)
try:
self.confirm_connected()
mode = os.stat(local_file).st_mode
remote_dir = os.path.dirname(remote_file)
if not self.exists(remote_dir):
self.sftp.mkdir(remote_dir)
self.sftp.put(local_file, remote_file)
self.sftp.chmod(remote_file, mode)
return True, None
except Exception as e:
return False, e
def download(self, src, target):
remote_file = src
local_file = target
self.confirm_connected()
try:
local_dir = os.path.dirname(local_file)
if not os.path.exists(local_dir):
os.makedirs(local_dir)
mode = self.sftp.stat(remote_file).st_mode
self.sftp.get(remote_file, local_file)
os.chmod(local_file, mode)
return True, None
except Exception as e:
return False, e
def delete(self, path):
path = os.path.join(self.sftp_root_path, path)
self.confirm_connected()
if not self.exists(path):
raise FileNotFoundError('File not exist error(%s)' % path)
try:
self.sftp.remove(path)
return True, None
except Exception as e:
return False, e
def check_dir_exist(self, d):
self.confirm_connected()
try:
self.sftp.stat(d)
return True
except Exception:
return False
def mkdir(self, dirs):
self.confirm_connected()
try:
if not self.exists(dirs):
self.sftp.mkdir(dirs)
return True, None
except Exception as e:
return False, e
def exists(self, target):
self.confirm_connected()
try:
self.sftp.stat(target)
return True
except:
return False
def close(self):
self.sftp.close()
self.ssh.close()

2
apps/common/tasks.py

@ -1,11 +1,11 @@
import os
import jms_storage
from celery import shared_task
from django.conf import settings
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 .utils import get_logger
logger = get_logger(__file__)

6
apps/common/utils/connection.py

@ -47,7 +47,7 @@ class Subscription:
self.ch = pb.ch
self.sub = sub
self.unsubscribed = False
logger.info("Subscribed to channel: ", sub)
logger.info(f"Subscribed to channel: {sub}")
def _handle_msg(self, _next, error, complete):
"""
@ -106,11 +106,11 @@ class Subscription:
def unsubscribe(self):
self.unsubscribed = True
logger.info("Unsubscribed from channel: ", self.sub)
logger.info(f"Unsubscribed from channel: {self.sub}")
try:
self.sub.close()
except Exception as e:
logger.warning('Unsubscribe msg error: {}'.format(e))
logger.warning(f'Unsubscribe msg error: {e}')
def retry(self, _next, error, complete):
logger.info('Retry subscribe channel: {}'.format(self.ch))

12
apps/common/utils/verify_code.py

@ -20,16 +20,20 @@ logger = get_logger(__file__)
be executed to send SMS messages"""
)
)
def send_sms_async(target, code):
SMS().send_verify_code(target, code)
def send_sms_async(target, code, user_info):
SMS().send_verify_code(target, code, user_info=user_info)
class SendAndVerifyCodeUtil(object):
KEY_TMPL = 'auth-verify-code-{}'
def __init__(self, target, code=None, key=None, backend='email', timeout=None, **kwargs):
def __init__(
self, target, code=None, key=None, backend='email',
user_info=None, timeout=None, **kwargs
):
self.code = code
self.target = target
self.user_info = user_info
self.backend = backend
self.key = key or self.KEY_TMPL.format(target)
self.timeout = settings.VERIFY_CODE_TTL if timeout is None else timeout
@ -78,7 +82,7 @@ class SendAndVerifyCodeUtil(object):
return code
def __send_with_sms(self):
send_sms_async.apply_async(args=(self.target, self.code), priority=100)
send_sms_async.apply_async(args=(self.target, self.code, self.user_info), priority=100)
def __send_with_email(self):
subject = self.other_args.get('subject', '')

2
apps/i18n/_translator/base.py

@ -11,7 +11,7 @@ class BaseTranslateManager:
SEPARATOR = "<SEP>"
LANG_MAPPER = {
'ja': 'Japanese',
'zh_hant': 'Taiwan',
'zh_hant': 'Traditional Chinese',
# 'en': 'English',
}

848
apps/i18n/core/en/LC_MESSAGES/django.po

File diff suppressed because it is too large Load Diff

865
apps/i18n/core/ja/LC_MESSAGES/django.po

File diff suppressed because it is too large Load Diff

964
apps/i18n/core/zh/LC_MESSAGES/django.po

File diff suppressed because it is too large Load Diff

862
apps/i18n/core/zh_Hant/LC_MESSAGES/django.po

File diff suppressed because it is too large Load Diff

4
apps/i18n/koko/en.json

@ -69,5 +69,7 @@
"VerifyCode": "Verify Code",
"WaitFileTransfer": "Wait file transfer to finish",
"WebSocketClosed": "WebSocket closed",
"Writable": "Writable"
"Writable": "Writable",
"UploadStart": "Upload start",
"UploadEnd": "Upload completed, please wait for further processing"
}

4
apps/i18n/koko/ja.json

@ -69,5 +69,7 @@
"VerifyCode": "認証コード",
"WaitFileTransfer": "ファイル転送終了待ち",
"WebSocketClosed": "WebSocket 閉店",
"Writable": "書き込み可能"
"Writable": "書き込み可能",
"UploadStart": "アップロード開始",
"UploadEnd": "アップロードが完了しました。後の処理をお待ちください"
}

2
apps/i18n/koko/zh.json

@ -65,6 +65,8 @@
"UploadSuccess": "上传成功",
"UploadTips": "将文件拖到此处,或点击上传",
"UploadTitle": "上传文件",
"UploadStart": "上传开始",
"UploadEnd": "上传已完成,请等待后续处理",
"User": "用户",
"VerifyCode": "验证码",
"WaitFileTransfer": "等待文件传输结束",

4
apps/i18n/koko/zh_hant.json

@ -69,5 +69,7 @@
"VerifyCode": "驗證碼",
"WaitFileTransfer": "等待文件傳輸結束",
"WebSocketClosed": "WebSocket 已關閉",
"Writable": "讀寫"
"Writable": "讀寫",
"UploadStart": "上傳開始",
"UploadEnd": "上傳已完成,請等待後續處理"
}

35
apps/i18n/lina/en.json

@ -66,11 +66,12 @@
"AddSuccessMsg": "Add successful",
"AddUserGroupToThisPermission": "Add user groups",
"AddUserToThisPermission": "Add users",
"AddVariable": "Add Variable",
"Address": "Address",
"AdhocCreate": "Create the command",
"AdhocDetail": "Command details",
"AdhocManage": "Script",
"AdhocUpdate": "Update the command",
"AdhocUpdate": "Update Script",
"Advanced": "Advanced settings",
"AfterChange": "After changes",
"AjaxError404": "404 request error",
@ -134,7 +135,7 @@
"AssetBulkUpdateTips": "Network devices, cloud services, web, batch updating of zones not supported",
"AssetChangeSecretCreate": "Create account secret change",
"AssetChangeSecretUpdate": "Update account secret change",
"AssetData": "Asset",
"AssetData": "Asset data",
"AssetDetail": "Asset details",
"AssetList": "Assets",
"AssetListHelpMessage": "On the left is the asset tree. right-click to create, delete or modify tree nodes. assets are also organized in node form. on the right are the assets under this node. \n",
@ -176,6 +177,8 @@
"AwaitingMyApproval": "Assigned",
"Azure": "Azure (China)",
"Azure_Int": "Azure (International)",
"AzureKeyVault": "Azure vault",
"HashicorpVault": "HCP vault",
"Backup": "Backup",
"BackupAccountsHelpText": "Backup account information externally. it can be stored in an external system or sent via email, supporting segmented delivery.",
"BadConflictErrorMsg": "Refreshing, please try again later",
@ -199,7 +202,7 @@
"BaseCommandFilterAclList": "Command filter",
"BaseConnectMethodACL": "Connect Method ACL",
"BaseFlowSetUp": "Flow Set Up",
"BaseJobManagement": "Job Management",
"BaseJobManagement": "Job List",
"BaseLoginLog": "Login Log",
"BaseMyAssets": "My Assets",
"BaseOperateLog": "Operate Log",
@ -255,6 +258,7 @@
"ChangeField": "Change field",
"ChangeOrganization": "Change organization",
"ChangePassword": "Change password",
"ChangeSecretAccountHelpText": "For accounts in the same asset, if there is a switch-from relationship, the password change should not be performed in the same task, but should be divided into two tasks for execution separately.",
"ChangeSecretParams": "Change secret parameters",
"ChangeViewHelpText": "Click to switch different views",
"Chat": "Chat",
@ -414,6 +418,7 @@
"DeclassificationLogNum": "Password change logs",
"DefaultDatabase": "Default database",
"DefaultPort": "Default port",
"DefaultValue": "Default value",
"Delete": "Delete",
"DeleteConfirmMessage": "Deletion is irreversible, do you wish to continue?",
"DeleteErrorMsg": "Delete failed",
@ -503,6 +508,7 @@
"ExportOnlyFiltered": "Export filtered items",
"ExportOnlySelectedItems": "Export selected items",
"ExportRange": "Export range",
"ExtraArgsPlaceholder": "One option per line, for example:\nOption 1: Value 1\nOption 2: Value 2",
"FC": "Fusion compute",
"FTPFileNotStored": "The file has not been saved to storage yet, please check back later.",
"FTPStorageNotEnabled": "The file storage function is not enabled. Please modify the configuration file and add the following configuration: FTP_FILE_MAX_STORE=100 (supports saving files within 100M)",
@ -647,8 +653,8 @@
"JobCenter": "Job center",
"JobCreate": "Create job",
"JobDetail": "Job details",
"JobExecutionLog": "Job logs",
"JobManagement": "Jobs",
"JobExecutionLog": "Executions",
"JobManagement": "Job List",
"JobUpdate": "Update the job",
"KingSoftCloud": "KingSoft cloud",
"KokoSetting": "KoKo",
@ -674,6 +680,8 @@
"LicenseForTest": "Test purpose license, this license is only for testing (poc) and demonstration",
"LicenseReachedAssetAmountLimit": "The assets has exceeded the license limit",
"LicenseWillBe": "License expiring soon",
"ListPreference": "List preferences",
"LoadTemplate": "Load template",
"Loading": "Loading",
"LockedIP": "Locked ip {count}",
"Log": "Log",
@ -819,6 +827,7 @@
"OperateLog": "Operate logs",
"OperationLogNum": "Operation logs",
"Options": "Options",
"OracleDBNameHelpText": "Fill in the SID or service name of the Oracle database (Service Name)",
"OrgAdmin": "Organization admin",
"OrgAuditor": "Organizational auditors",
"OrgName": "Authorized organization",
@ -899,7 +908,7 @@
"ProfileSetting": "Profile info",
"Project": "Project name",
"Prompt": "Prompt",
"Proportion": "New this week",
"Proportion": "Proportion",
"ProportionOfAssetTypes": "Asset type proportion",
"Protocol": "Protocol",
"Protocols": "Protocols",
@ -1062,7 +1071,7 @@
"Secure": "Security",
"Security": "Security",
"Select": "Select",
"SelectAdhoc": "Select command",
"SelectAdhoc": "Select command template",
"SelectAll": "Select all",
"SelectAtLeastOneAssetOrNodeErrMsg": "Select at least one asset or node",
"SelectAttrs": "Select attributes",
@ -1203,7 +1212,6 @@
"SystemTasks": "Tasks",
"SystemTools": "Tools",
"TableColSetting": "Select visible attribute columns",
"TableSetting": "Table preferences",
"TagCreate": "Create tag",
"TagInputFormatValidation": "Tag format error, the correct format is: name:value",
"TagList": "Tags",
@ -1226,6 +1234,7 @@
"TemplateCreate": "Create template",
"TemplateHelpText": "When selecting a template to add, accounts that do not exist under the asset will be automatically created and pushed",
"TemplateManagement": "Templates",
"Templates": "Templates",
"TencentCloud": "Tencent cloud",
"Terminal": "Components",
"TerminalDetail": "Terminal details",
@ -1354,6 +1363,7 @@
"Valid": "Valid",
"Variable": "Variable",
"VariableHelpText": "You can use {{ key }} to read built-in variables in commands",
"VariableName": "Variable name",
"VaultHCPMountPoint": "The mount point of the Vault server, default is jumpserver",
"VaultHelpText": "1. for security reasons, vault storage must be enabled in the configuration file.<br>2. after enabled, fill in other configurations, and perform tests.<br>3. carry out data synchronization, which is one-way, only syncing from the local database to the distant vault, once synchronization is completed, the local database will no longer store passwords, please back up your data.<br>4. after modifying vault configuration the second time, you need to restart the service.",
"VerificationCodeSent": "Verification code has been sent",
@ -1401,5 +1411,12 @@
"ZoneUpdate": "Update the zone",
"disallowSelfUpdateFields": "Not allowed to modify the current fields yourself",
"forceEnableMFAHelpText": "If force enable, user can not disable by themselves",
"removeWarningMsg": "Are you sure you want to remove"
"removeWarningMsg": "Are you sure you want to remove",
"setVariable": "Set variable",
"JobsAudit": "Job audits",
"JobList": "Job List",
"StopJobMsg": "Stop job successfully",
"ExtraArgsFormatError": "Format error, please enter according to the requirements",
"MFAOnlyAdminUsers": "Globally: Only admin",
"MFAAllUsers": "Globally: All users"
}

36
apps/i18n/lina/ja.json

@ -66,11 +66,12 @@
"AddSuccessMsg": "追加成功",
"AddUserGroupToThisPermission": "ユーザーグループを追加",
"AddUserToThisPermission": "ユーザーを追加する",
"AddVariable": "パラメータを追加",
"Address": "アドレス",
"AdhocCreate": "アドホックコマンドを作成",
"AdhocDetail": "コマンド詳細",
"AdhocManage": "スクリプト管理",
"AdhocUpdate": "コマンドを更新",
"AdhocUpdate": "更新スクリプト",
"Advanced": "高度な設定",
"AfterChange": "変更後",
"AjaxError404": "404 リクエストエラー",
@ -176,6 +177,8 @@
"AwaitingMyApproval": "私の承認待ち",
"Azure": "Azure(中国)",
"Azure_Int": "アジュール(インターナショナル)",
"AzureKeyVault": "Azure vault",
"HashicorpVault": "HCP vault",
"Backup": "バックアップ",
"BackupAccountsHelpText": "アカウント情報を外部にバックアップする。外部システムに保存するかメールを送信することもできます、セクション方式をサポートしています",
"BadConflictErrorMsg": "更新中です、しばらくお待ちください",
@ -199,7 +202,7 @@
"BaseCommandFilterAclList": "コマンドフィルタ",
"BaseConnectMethodACL": "接続方法の承認",
"BaseFlowSetUp": "フロー設定",
"BaseJobManagement": "作業管理",
"BaseJobManagement": "作業列表",
"BaseLoginLog": "ログインログ",
"BaseMyAssets": "私の資産",
"BaseOperateLog": "Actionログ",
@ -269,6 +272,7 @@
"ChangeOrganization": "組織の 변경",
"ChangePassword": "パスワード更新",
"ChangeReceiver": "メッセージ受信者の変更",
"ChangeSecretAccountHelpText": "同じ資産内のアカウントに切り替え元関係がある場合、パスワード変更は同じタスクで実行せず、2 つのタスクに分割して別々に実行する必要があります。",
"ChangeSecretParams": "パスワード変更パラメータ",
"ChangeViewHelpText": "クリックして異なるビューを切り替え",
"Chat": "チャット",
@ -429,6 +433,7 @@
"DeclassificationLogNum": "パスワード変更ログ数",
"DefaultDatabase": "デフォルトのデータベース",
"DefaultPort": "デフォルトポート",
"DefaultValue": "デフォルト値",
"Delete": "削除",
"DeleteConfirmMessage": "一度削除すると復元はできません、続けますか?",
"DeleteErrorMsg": "削除に失敗",
@ -506,9 +511,10 @@
"ExcludeSymbol": "文字の除外",
"ExecCloudSyncErrorMsg": "クラウドアカウントの設定が完全でないので、更新して再試行してください",
"Execute": "実行",
"ExecuteAfterSaving": "保存後に実行",
"ExecuteOnce": "一度実行する",
"ExecutionDetail": "Action詳細",
"ExecutionList": "実行リスト",
"ExecutionList": "実行記録",
"ExistError": "この要素は既に存在します",
"Existing": "既に存在しています",
"ExpirationTimeout": "有効期限タイムアウト(秒)",
@ -519,6 +525,8 @@
"ExportOnlyFiltered": "検索結果のみをエクスポート",
"ExportOnlySelectedItems": "選択オプションのみをエクスポート",
"ExportRange": "エクスポート範囲",
"ExtraArgsFormatError": "書式が間違っています。要件に従って入力してください",
"ExtraArgsPlaceholder": "一行ごとに一つの選択肢を書く、例えば:\n選択肢1:値1\n選択肢2:値2\n",
"FC": "Fusion Compute",
"FTPFileNotStored": "ファイルはまだストレージに保存されていません、後で確認してください。",
"FTPStorageNotEnabled": "ファイルストレージ機能が有効になっていません、設定ファイルを変更し、次の設定を追加してください:FTP_FILE_MAX_STORE=100(100M以下のファイルを保存可能)",
@ -668,9 +676,11 @@
"JobCenter": "Actionセンター",
"JobCreate": "ジョブ作成",
"JobDetail": "作業詳細",
"JobExecutionLog": "作業ログ",
"JobManagement": "作業管理",
"JobExecutionLog": "実行記録",
"JobList": "作業リスト",
"JobManagement": "作業列表",
"JobUpdate": "アップデート作業",
"JobsAudit": "作業の監査",
"KingSoftCloud": "Kingsoftクラウド",
"KokoSetting": "KoKo 設定",
"LAN": "LAN",
@ -700,6 +710,8 @@
"LicenseForTest": "テスト用ライセンス。このライセンスはテスト(PoC)とデモンストレーションにのみ使用されます",
"LicenseReachedAssetAmountLimit": "資産の数量がライセンスの数量制限を超えています",
"LicenseWillBe": "ライセンスは間もなく ",
"ListPreference": "リスト設定",
"LoadTemplate": "テンプレートからロード",
"Loading": "読み込み中",
"LockedIP": "IP {count} つがロックされました",
"Log": "ログ",
@ -851,6 +863,7 @@
"OperateLog": "操作ログ",
"OperationLogNum": "Actionログ数",
"Options": "オプション",
"OracleDBNameHelpText": "Oracle データベースの SID またはサービス名 (サービス名) を入力します。",
"OrgAdmin": "管理",
"OrgAuditor": "組織監査員",
"OrgName": "Actionグループの名前",
@ -1053,7 +1066,7 @@
"Rules": "規則",
"Run": "Action",
"RunAgain": "再実行",
"RunAs": "実行ユーザー",
"RunAs": "実行アカウント (じっこうアカウント)",
"RunCommand": "コマンドの実行",
"RunJob": "ジョブを実行",
"RunSucceed": "タスクが成功",
@ -1097,7 +1110,7 @@
"Secure": "安全",
"Security": "セキュリティ設定",
"Select": "選択",
"SelectAdhoc": "コマンドの選択",
"SelectAdhoc": "スクリプトテンプレートを選択",
"SelectAll": "全選択",
"SelectAtLeastOneAssetOrNodeErrMsg": "アセットまたはノードは少なくとも一つ選択してください",
"SelectAttrs": "属性の選択",
@ -1182,6 +1195,7 @@
"StatusYellow": "最近、実行に失敗があり。",
"Step": "ステップ",
"Stop": "停止",
"StopJobMsg": "成功を停止",
"StopLogOutput": "ask Canceled:現在のタスク(currentTaskId)は手動で停止されました。各タスクの進行状況が異なるため、以下はタスクの最終実行結果です。実行が失敗した場合は、タスクが正常に停止されました。",
"Storage": "ストレージ",
"StorageSetting": "ストレージ設定",
@ -1244,7 +1258,6 @@
"SystemTasks": "タスクリスト",
"SystemTools": "システムツール",
"TableColSetting": "表示属性列の選択",
"TableSetting": "テーブル環境設定",
"TagCreate": "ラベルの作成",
"TagInputFormatValidation": "ラベルの形式が間違っています、正しい形式は:name:value",
"TagList": "タグ一覧",
@ -1266,7 +1279,8 @@
"TemplateAdd": "テンプレート追加",
"TemplateCreate": "テンプレート作成",
"TemplateHelpText": "テンプレートを選択して追加すると、資産の下に存在しないアカウントが自動的に作成され、プッシュされます",
"TemplateManagement": "テンプレート管理",
"TemplateManagement": "テンプレート一覧",
"Templates": "テンプレート",
"TencentCloud": "テンセントクラウド",
"Terminal": "コンポーネント設定",
"TerminalDetail": "コンポーネントの詳細",
@ -1396,6 +1410,7 @@
"Valid": "有効",
"Variable": "変数",
"VariableHelpText": "コマンド中で {{ key }} を使用して内蔵変数を読み取ることができます",
"VariableName": "変数名",
"VaultHCPMountPoint": "Vault サーバのマウントポイント、デフォルトはjumpserver",
"VaultHelpText": "1. セキュリティ上の理由により、設定ファイルで Vault ストレージをオンにする必要があります。<br>2. オンにした後、他の設定を入力してテストを行います。<br>3. データ同期を行います。同期は一方向です。ローカルデータベースからリモートの Vault にのみ同期します。同期が終了すればローカルデータベースはパスワードを保管していませんので、データのバックアップをお願いします。<br>4. Vault の設定を二度変更した後はサービスを再起動する必要があります。",
"VerificationCodeSent": "認証コードが送信されました",
@ -1443,5 +1458,6 @@
"ZoneUpdate": "更新エリア",
"disallowSelfUpdateFields": "現在のフィールドを自分で変更することは許可されていません",
"forceEnableMFAHelpText": "強制的に有効化すると、ユーザーは自分で無効化することができません。",
"removeWarningMsg": "削除してもよろしいですか"
"removeWarningMsg": "削除してもよろしいですか",
"setVariable": "パラメータ設定"
}

38
apps/i18n/lina/zh.json

@ -66,11 +66,12 @@
"AddSuccessMsg": "添加成功",
"AddUserGroupToThisPermission": "添加用户组",
"AddUserToThisPermission": "添加用户",
"AddVariable": "添加参数",
"Address": "地址",
"AdhocCreate": "创建命令",
"AdhocDetail": "命令详情",
"AdhocManage": "脚本管理",
"AdhocUpdate": "更新命令",
"AdhocUpdate": "更新脚本",
"Advanced": "高级设置",
"AfterChange": "变更后",
"AjaxError404": "404 请求错误",
@ -176,6 +177,8 @@
"AwaitingMyApproval": "待我审批",
"Azure": "Azure (中国)",
"Azure_Int": "Azure (国际)",
"AzureKeyVault": "Azure vault",
"HashicorpVault": "HCP vault",
"Backup": "备份",
"BackupAccountsHelpText": "备份账号信息到外部。可以存储到外部系统或发送邮件,支持分段方式",
"BadConflictErrorMsg": "正在刷新中,请稍后再试",
@ -199,7 +202,7 @@
"BaseCommandFilterAclList": "命令过滤",
"BaseConnectMethodACL": "连接方式授权",
"BaseFlowSetUp": "流程设置",
"BaseJobManagement": "作业管理",
"BaseJobManagement": "作业列表",
"BaseLoginLog": "登录日志",
"BaseMyAssets": "我的资产",
"BaseOperateLog": "操作日志",
@ -255,6 +258,7 @@
"ChangeField": "变更字段",
"ChangeOrganization": "更改组织",
"ChangePassword": "更新密码",
"ChangeSecretAccountHelpText": "对于同一资产中的账号,如果存在切换自关系,则不应放在同一个任务中执行密码更改,而是应分成两个任务分别执行。",
"ChangeSecretParams": "改密参数",
"ChangeViewHelpText": "点击切换不同视图",
"Chat": "聊天",
@ -414,6 +418,7 @@
"DeclassificationLogNum": "改密日志数",
"DefaultDatabase": "默认数据库",
"DefaultPort": "默认端口",
"DefaultValue": "默认值",
"Delete": "删除",
"DeleteConfirmMessage": "删除后无法恢复,是否继续?",
"DeleteErrorMsg": "删除失败",
@ -491,9 +496,10 @@
"ExcludeSymbol": "排除字符",
"ExecCloudSyncErrorMsg": "云账号配置不完整,请更新后重试",
"Execute": "执行",
"ExecuteAfterSaving": "保存后执行",
"ExecuteOnce": "执行一次",
"ExecutionDetail": "执行详情",
"ExecutionList": "执行列表",
"ExecutionList": "执行记录",
"ExistError": "这个元素已经存在",
"Existing": "已存在",
"ExpirationTimeout": "过期超时时间(秒)",
@ -504,6 +510,7 @@
"ExportOnlyFiltered": "仅导出搜索结果",
"ExportOnlySelectedItems": "仅导出选择项",
"ExportRange": "导出范围",
"ExtraArgsPlaceholder": "每行一个选项,例如:\n选项1:值1\n选项2:值2\n",
"FC": "Fusion Compute",
"FTPFileNotStored": "文件尚未保存到存储中,请稍后查看。",
"FTPStorageNotEnabled": "文件存储功能未开启,请修改配置文件并添加配置:FTP_FILE_MAX_STORE=100(支持保存100M以内的文件)",
@ -650,8 +657,8 @@
"JobCenter": "作业中心",
"JobCreate": "创建作业",
"JobDetail": "作业详情",
"JobExecutionLog": "作业日志",
"JobManagement": "作业管理",
"JobExecutionLog": "执行记录",
"JobManagement": "作业列表",
"JobUpdate": "更新作业",
"KingSoftCloud": "金山云",
"KokoSetting": "KoKo 配置",
@ -677,6 +684,8 @@
"LicenseForTest": "测试用途许可证, 本许可证仅用于 测试(PoC)和演示",
"LicenseReachedAssetAmountLimit": "资产数量已经超过许可证数量限制",
"LicenseWillBe": "许可证即将在 ",
"ListPreference": "列表偏好",
"LoadTemplate": "从模板中加载",
"Loading": "加载中",
"LockedIP": "已锁定 IP {count} 个",
"Log": "日志",
@ -822,6 +831,7 @@
"OperateLog": "操作日志",
"OperationLogNum": "操作日志数",
"Options": "选项",
"OracleDBNameHelpText": "填写 Oracle 资料库的SID或服务名称(Service Name)",
"OrgAdmin": "组织管理员",
"OrgAuditor": "组织审计员",
"OrgName": "授权组织名称",
@ -1022,7 +1032,7 @@
"Rules": "规则",
"Run": "执行",
"RunAgain": "再次执行",
"RunAs": "运行用户",
"RunAs": "运行账号",
"RunCommand": "运行命令",
"RunJob": "运行作业",
"RunSucceed": "任务执行成功",
@ -1066,7 +1076,7 @@
"Secure": "安全",
"Security": "安全设置",
"Select": "选择",
"SelectAdhoc": "选择命令",
"SelectAdhoc": "选择脚本模板",
"SelectAll": "全选",
"SelectAtLeastOneAssetOrNodeErrMsg": "资产或者节点至少选择一项",
"SelectAttrs": "选择属性",
@ -1207,7 +1217,6 @@
"SystemTasks": "任务列表",
"SystemTools": "系统工具",
"TableColSetting": "选择可见属性列",
"TableSetting": "表格偏好",
"TagCreate": "创建标签",
"TagInputFormatValidation": "标签格式错误,正确格式为:name:value",
"TagList": "标签列表",
@ -1229,7 +1238,8 @@
"TemplateAdd": "模版添加",
"TemplateCreate": "创建模版",
"TemplateHelpText": "选择模版添加时,会自动创建资产下不存在的账号并推送",
"TemplateManagement": "模版管理",
"TemplateManagement": "模版列表",
"Templates": "模板",
"TencentCloud": "腾讯云",
"Terminal": "组件设置",
"TerminalDetail": "组件详情",
@ -1358,6 +1368,7 @@
"Valid": "有效",
"Variable": "变量",
"VariableHelpText": "您可以在命令中使用 {{ key }} 读取内置变量",
"VariableName": "变量名",
"VaultHCPMountPoint": "Vault 服务器的挂载点,默认为 jumpserver",
"VaultHelpText": "1. 由于安全原因,需要配置文件中开启 Vault 存储。<br>2. 开启后,填写其他配置,进行测试。<br>3. 进行数据同步,同步是单向的,只会从本地数据库同步到远端 Vault,同步完成本地数据库不再存储密码,请备份好数据。<br>4. 二次修改 Vault 配置后需重启服务。",
"VerificationCodeSent": "验证码已发送",
@ -1405,5 +1416,12 @@
"ZoneUpdate": "更新网域",
"disallowSelfUpdateFields": "不允许自己修改当前字段",
"forceEnableMFAHelpText": "如果强制启用,用户无法自行禁用",
"removeWarningMsg": "你确定要移除"
"removeWarningMsg": "你确定要移除",
"setVariable": "设置参数",
"JobsAudit": "作业审计",
"JobList": "作业列表",
"StopJobMsg": "停止成功",
"ExtraArgsFormatError": "格式错误,请按要求输入",
"MFAOnlyAdminUsers": "全局启用: 仅管理员",
"MFAAllUsers": "全局启用: 所有用户"
}

39
apps/i18n/lina/zh_hant.json

@ -83,12 +83,13 @@
"AddSystemUser": "添加系統用戶",
"AddUserGroupToThisPermission": "新增使用者組",
"AddUserToThisPermission": "新增使用者",
"AddVariable": "添加參數",
"Address": "地址",
"Addressee": "收件人",
"AdhocCreate": "創建命令",
"AdhocDetail": "命令詳情",
"AdhocManage": "腳本管理",
"AdhocUpdate": "更新命令",
"AdhocUpdate": "更新腳本",
"Admin": "管理員",
"AdminUser": "特權用戶",
"AdminUserCreate": "創建管理用戶",
@ -237,6 +238,8 @@
"AwaitingMyApproval": "待我審批",
"Azure": "Azure (中國)",
"Azure_Int": "Azure (國際)",
"AzureKeyVault": "Azure vault",
"HashicorpVault": "HCP vault",
"Backup": "備份",
"BackupAccountsHelpText": "備份帳號資訊至外部。可以儲存到外部系統或寄送郵件,支援分段方式",
"BadConflictErrorMsg": "正在刷新中,請稍後再試",
@ -260,7 +263,7 @@
"BaseCommandFilterAclList": "命令過濾",
"BaseConnectMethodACL": "連接方式授權",
"BaseFlowSetUp": "流程設定",
"BaseJobManagement": "作業",
"BaseJobManagement": "作業列表",
"BaseLoginLog": "登入日誌",
"BaseMyAssets": "我的資產",
"BaseOperateLog": "操作日誌",
@ -341,6 +344,7 @@
"ChangeOrganization": "更改組織",
"ChangePassword": "更改密碼",
"ChangeReceiver": "修改消息接收人",
"ChangeSecretAccountHelpText": "對於同一資產中的帳號,如果存在切換自關係,則不應放在同一個任務中執行密碼更改,而是應分成兩個任務分別執行。",
"ChangeSecretParams": "改密參數",
"ChangeViewHelpText": "點擊切換不同視圖",
"Charset": "字元集",
@ -549,6 +553,7 @@
"DefaultDatabase": "默認資料庫",
"DefaultPort": "默認埠",
"DefaultProtocol": "默認協議, 添加資產時預設會選擇",
"DefaultValue": "預設值",
"Defaults": "預設值",
"Delete": "刪除",
"DeleteConfirmMessage": "刪除後無法恢復,是否繼續?",
@ -648,12 +653,13 @@
"ExcludeSymbol": "排除字元",
"ExecCloudSyncErrorMsg": "雲帳號配置不完整,請更新後重試",
"Execute": "執行",
"ExecuteAfterSaving": "保存後執行",
"ExecuteCycle": "執行週期",
"ExecuteFailedCommand": "執行失敗命令",
"ExecuteOnce": "執行一次",
"Execution": "執行歷史",
"ExecutionDetail": "執行詳情",
"ExecutionList": "執行列表",
"ExecutionList": "執行記錄",
"ExecutionTimes": "執行次數",
"ExistError": "這個元素已經存在",
"Existing": "已存在",
@ -666,6 +672,8 @@
"ExportOnlyFiltered": "僅匯出搜索結果",
"ExportOnlySelectedItems": "僅匯出選擇項",
"ExportRange": "匯出範圍",
"ExtraArgsFormatError": "格式錯誤,請按要求輸入",
"ExtraArgsPlaceholder": "每行一個選項,例如:\n選項1:值1\n選項2:值2\n",
"FAILURE": "失敗",
"FC": "Fusion Compute",
"FTPFileNotStored": "檔案尚未儲存到儲存中,請稍後查看。",
@ -845,12 +853,13 @@
"JobCenter": "作業中心",
"JobCreate": "創建作業",
"JobDetail": "作業詳情",
"JobExecutionLog": "作業日誌",
"JobExecutionLog": "執行記錄",
"JobList": "作業管理",
"JobManagement": "作業",
"JobManagement": "作業列表",
"JobName": "作業名稱",
"JobType": "作業類型",
"JobUpdate": "更新作業",
"JobsAudit": "作業審核",
"Key": "鍵",
"KingSoftCloud": "金山雲",
"KokoSetting": "KoKo 配置",
@ -898,7 +907,9 @@
"LicenseWillBe": "許可證即將在 ",
"LinuxAdminUser": "Linux 特權用戶",
"LinuxUserAffiliateGroup": "用戶附屬組",
"ListPreference": "列表偏好",
"LoadStatus": "負載狀態",
"LoadTemplate": "從模版中加載",
"Loading": "載入中",
"LockedIP": "已鎖定 IP {count} 個",
"Log": "日誌",
@ -1089,7 +1100,7 @@
"OperationLogNum": "操作日誌數",
"Ops": "任務",
"Options": "選項",
"OracleDBNameHelpText": "提示:填寫 Oracle 資料庫的SID或服務名稱(Service Name)",
"OracleDBNameHelpText": "填寫 Oracle 資料庫的SID或服務名稱(Service Name)",
"OrgAdmin": "組織管理員",
"OrgAuditor": "組織審計員",
"OrgName": "授權組織名稱",
@ -1356,7 +1367,7 @@
"Rules": "規則",
"Run": "運行",
"RunAgain": "再次執行",
"RunAs": "執行使用者",
"RunAs": "運行賬號",
"RunCommand": "運行命令",
"RunJob": "運行作業",
"RunSucceed": "任務執行成功",
@ -1413,7 +1424,7 @@
"SecuritySetting": "安全設定",
"Select": "選擇",
"SelectAccount": "選擇帳號",
"SelectAdhoc": "選擇命令",
"SelectAdhoc": "選擇腳本模板",
"SelectAll": "全選",
"SelectAtLeastOneAssetOrNodeErrMsg": "資產或者節點至少選擇一項",
"SelectAttrs": "選擇屬性",
@ -1507,6 +1518,7 @@
"Step": "步驟",
"Stop": "停止",
"StopJob": "停止作業",
"StopJobMsg": "停止成功",
"StopLogOutput": "任務已取消:當前任務(currentTaskId)已被手動停止。由於每個任務的執行進度不同,以下是任務的最終執行結果。執行失敗表示任務已成功停止。",
"Storage": "儲存設置",
"StorageConfiguration": "儲存配置",
@ -1587,7 +1599,6 @@
"SystemUsers": "系統用戶",
"TableColSetting": "選擇可見屬性列",
"TableColSettingInfo": "請選擇您想顯示的列表詳細資訊。",
"TableSetting": "表單偏好",
"TagCreate": "創建標籤",
"TagInputFormatValidation": "Label format error, correct format is: name:value",
"TagList": "標籤列表",
@ -1609,14 +1620,14 @@
"TempPassword": "臨時密碼有效期為 300 秒,使用後立刻失效",
"TempPasswordTip": "臨時密碼有效時間為 300 秒,使用後立即失效",
"TempToken": "臨時密碼",
"Template": "模板管理",
"Template": "模板列表",
"TemplateAdd": "模板添加",
"TemplateCreate": "創建模板",
"TemplateDetail": "模板詳情",
"TemplateHelpText": "選擇模板添加時,會自動創建資產下不存在的帳號並推送",
"TemplateManagement": "模板管理",
"TemplateManagement": "模版列表",
"TemplateUpdate": "更新模板",
"Templates": "模板管理",
"Templates": "模板列表",
"TencentCloud": "騰訊雲",
"Terminal": "組件設置",
"TerminalDetail": "組件詳情",
@ -1792,6 +1803,7 @@
"Value": "值",
"Variable": "變數",
"VariableHelpText": "您可以在命令中使用 {{ key }} 讀取內建變數",
"VariableName": "變數名",
"Vault": "密碼匣子",
"VaultHCPMountPoint": "重新嘗試所選",
"VaultHelpText": "1. 由於安全原因,需要配置文件中開啟 Vault 儲存。<br>2. 開啟後,填寫其他配置,進行測試。<br>3. 進行數據同步,同步是單向的,只會從本地資料庫同步到遠端 Vault,同步完成本地資料庫不再儲存密碼,請備份好數據。<br>4. 二次修改 Vault 配置後需重啟服務。",
@ -2161,7 +2173,7 @@
"riskLevel": "風險等級",
"rows": "行",
"run": "執行",
"runAs": "運行用戶",
"runAs": "運行賬號",
"runSucceed": "任務執行成功",
"runTimes": "執行次數",
"running": "運行中",
@ -2192,6 +2204,7 @@
"setFeiShu": "設置飛書認證",
"setLark": "設置 Lark 認證",
"setSlack": "設置 Slack 認證",
"setVariable": "設置參數",
"setWeCom": "設置企業微信認證",
"setting": "設置",
"siteUrl": "當前站點URL",

1
apps/i18n/luna/en.json

@ -36,6 +36,7 @@
"Close Right Tabs": "Close Right Tabs",
"Close split connect": "Close split connect",
"Collapse": "Collapse",
"Command": "Command",
"Command Line": "Command Line",
"Command line": "Command line",
"CommandBar": "Command bar",

1
apps/i18n/luna/ja.json

@ -35,6 +35,7 @@
"Close Other Tabs": "その他を閉じる",
"Close Right Tabs": "右側を閉じる",
"Close split connect": "分割表示を閉じる",
"Command": "命令",
"Command Line": "コマンドライン",
"Command line": "命令行",
"CommandBar": "コマンドバー",

1
apps/i18n/luna/zh.json

@ -35,6 +35,7 @@
"Close Other Tabs": "关闭其它",
"Close Right Tabs": "关闭右侧",
"Close split connect": "关闭分屏",
"Command": "命令",
"Command Line": "命令行",
"Command line": "连接命令行",
"CommandBar": "命令栏",

1
apps/i18n/luna/zh_hant.json

@ -35,6 +35,7 @@
"Close Other Tabs": "關閉其它",
"Close Right Tabs": "關閉右側",
"Close split connect": "關閉分屏",
"Command": "指令",
"Command Line": "命令行",
"Command line": "連接命令行",
"CommandBar": "指令欄",

14
apps/jumpserver/conf.py

@ -258,10 +258,17 @@ class Config(dict):
# Vault
'VAULT_ENABLED': False,
'VAULT_BACKEND': 'local',
'VAULT_HCP_HOST': '',
'VAULT_HCP_TOKEN': '',
'VAULT_HCP_MOUNT_POINT': 'jumpserver',
'VAULT_AZURE_HOST': '',
'VAULT_AZURE_CLIENT_ID': '',
'VAULT_AZURE_CLIENT_SECRET': '',
'VAULT_AZURE_TENANT_ID': '',
'HISTORY_ACCOUNT_CLEAN_LIMIT': 999,
# Cache login password
@ -346,6 +353,7 @@ class Config(dict):
'AUTH_OPENID_REALM_NAME': None,
'OPENID_ORG_IDS': [DEFAULT_ID],
# Raidus 认证
'AUTH_RADIUS': False,
'RADIUS_SERVER': 'localhost',
@ -480,6 +488,12 @@ class Config(dict):
'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS / SAML2
'LOGIN_REDIRECT_MSG_ENABLED': True,
# 人脸识别
'FACE_RECOGNITION_ENABLED': False,
'FACE_RECOGNITION_DISTANCE_THRESHOLD': 0.35,
'FACE_RECOGNITION_COSINE_THRESHOLD': 0.95,
'SMS_ENABLED': False,
'SMS_BACKEND': '',
'SMS_CODE_LENGTH': 4,

3
apps/jumpserver/settings/_xpack.py

@ -18,10 +18,13 @@ if not XPACK_DISABLED:
XPACK_TEMPLATES_DIR = []
XPACK_CONTEXT_PROCESSOR = []
XPACK_LICENSE_IS_VALID = False
XPACK_LICENSE_EDITION = ""
XPACK_LICENSE_INFO = {
'corporation': corporation,
}
XPACK_LICENSE_CONTENT = 'community'
if XPACK_ENABLED:
from xpack.utils import get_xpack_templates_dir, get_xpack_context_processor

15
apps/jumpserver/settings/auth.py

@ -235,10 +235,17 @@ AUTH_TEMP_TOKEN = CONFIG.AUTH_TEMP_TOKEN
# Vault
VAULT_ENABLED = CONFIG.VAULT_ENABLED
VAULT_BACKEND = CONFIG.VAULT_BACKEND
VAULT_HCP_HOST = CONFIG.VAULT_HCP_HOST
VAULT_HCP_TOKEN = CONFIG.VAULT_HCP_TOKEN
VAULT_HCP_MOUNT_POINT = CONFIG.VAULT_HCP_MOUNT_POINT
VAULT_AZURE_HOST = CONFIG.VAULT_AZURE_HOST
VAULT_AZURE_CLIENT_ID = CONFIG.VAULT_AZURE_CLIENT_ID
VAULT_AZURE_CLIENT_SECRET = CONFIG.VAULT_AZURE_CLIENT_SECRET
VAULT_AZURE_TENANT_ID = CONFIG.VAULT_AZURE_TENANT_ID
HISTORY_ACCOUNT_CLEAN_LIMIT = CONFIG.HISTORY_ACCOUNT_CLEAN_LIMIT
# Other setting
@ -298,6 +305,11 @@ def get_file_md5(filepath):
return m.hexdigest()
# 人脸验证
FACE_RECOGNITION_ENABLED = CONFIG.FACE_RECOGNITION_ENABLED
FACE_RECOGNITION_DISTANCE_THRESHOLD = CONFIG.FACE_RECOGNITION_DISTANCE_THRESHOLD
FACE_RECOGNITION_COSINE_THRESHOLD = CONFIG.FACE_RECOGNITION_COSINE_THRESHOLD
AUTH_CUSTOM = CONFIG.AUTH_CUSTOM
AUTH_CUSTOM_FILE_MD5 = CONFIG.AUTH_CUSTOM_FILE_MD5
AUTH_CUSTOM_FILE_PATH = os.path.join(PROJECT_DIR, 'data', 'auth', 'main.py')
@ -306,11 +318,12 @@ if AUTH_CUSTOM and AUTH_CUSTOM_FILE_MD5 == get_file_md5(AUTH_CUSTOM_FILE_PATH):
AUTHENTICATION_BACKENDS.append(AUTH_BACKEND_CUSTOM)
MFA_BACKEND_OTP = 'authentication.mfa.otp.MFAOtp'
MFA_BACKEND_FACE = 'authentication.mfa.face.MFAFace'
MFA_BACKEND_RADIUS = 'authentication.mfa.radius.MFARadius'
MFA_BACKEND_SMS = 'authentication.mfa.sms.MFASms'
MFA_BACKEND_CUSTOM = 'authentication.mfa.custom.MFACustom'
MFA_BACKENDS = [MFA_BACKEND_OTP, MFA_BACKEND_RADIUS, MFA_BACKEND_SMS]
MFA_BACKENDS = [MFA_BACKEND_OTP, MFA_BACKEND_RADIUS, MFA_BACKEND_SMS, MFA_BACKEND_FACE]
MFA_CUSTOM = CONFIG.MFA_CUSTOM
MFA_CUSTOM_FILE_MD5 = CONFIG.MFA_CUSTOM_FILE_MD5

2
apps/jumpserver/settings/custom.py

@ -116,7 +116,7 @@ EMAIL_CUSTOM_USER_CREATED_BODY = CONFIG.EMAIL_CUSTOM_USER_CREATED_BODY
EMAIL_CUSTOM_USER_CREATED_SIGNATURE = CONFIG.EMAIL_CUSTOM_USER_CREATED_SIGNATURE
DISPLAY_PER_PAGE = CONFIG.DISPLAY_PER_PAGE
DEFAULT_EXPIRED_YEARS = 70
DEFAULT_EXPIRED_YEARS = CONFIG.DEFAULT_EXPIRED_YEARS
USER_GUIDE_URL = CONFIG.USER_GUIDE_URL
HTTP_LISTEN_PORT = CONFIG.HTTP_LISTEN_PORT
WS_LISTEN_PORT = CONFIG.WS_LISTEN_PORT

2
apps/jumpserver/views/other.py

@ -101,7 +101,7 @@ class ResourceDownload(TemplateView):
MRD_VERSION=10.6.7
OPENSSH_VERSION=v9.4.0.0
TINKER_VERSION=v0.1.6
VIDEO_PLAYER_VERSION=0.1.9
VIDEO_PLAYER_VERSION=0.2.0
CLIENT_VERSION=v2.1.3
"""

15
apps/notifications/notifications.py

@ -127,13 +127,16 @@ class Message(metaclass=MessageType):
def get_html_msg(self) -> dict:
return self.get_common_msg()
def get_markdown_msg(self) -> dict:
@staticmethod
def html_to_markdown(html_msg):
h = HTML2Text()
h.body_width = 300
msg = self.get_html_msg()
content = msg['message']
msg['message'] = h.handle(content)
return msg
h.body_width = 0
content = html_msg['message']
html_msg['message'] = h.handle(content)
return html_msg
def get_markdown_msg(self) -> dict:
return self.html_to_markdown(self.get_html_msg())
def get_text_msg(self) -> dict:
h = HTML2Text()

6
apps/ops/ansible/runner.py

@ -78,7 +78,7 @@ class AdHocRunner:
class PlaybookRunner:
def __init__(self, inventory, playbook, project_dir='/tmp/', callback=None):
def __init__(self, inventory, playbook, project_dir='/tmp/', callback=None, extra_vars=None, ):
self.id = uuid.uuid4()
self.inventory = inventory
@ -89,6 +89,9 @@ class PlaybookRunner:
self.cb = callback
self.isolate = True
self.envs = {}
if extra_vars is None:
extra_vars = {}
self.extra_vars = extra_vars
def copy_playbook(self):
entry = os.path.basename(self.playbook)
@ -119,6 +122,7 @@ class PlaybookRunner:
status_handler=self.cb.status_handler,
host_cwd=self.project_dir,
envvars=self.envs,
extravars=self.extra_vars,
**kwargs
)
return self.cb

1
apps/ops/api/__init__.py

@ -4,3 +4,4 @@ from .adhoc import *
from .celery import *
from .job import *
from .playbook import *
from .variable import *

8
apps/ops/api/adhoc.py

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from common.api.generic import JMSBulkModelViewSet
from common.utils.http import is_true
@ -20,10 +21,15 @@ class AdHocViewSet(JMSBulkModelViewSet):
search_fields = ('name', 'comment')
filterset_fields = ['scope', 'creator']
def allow_bulk_destroy(self, qs, filtered):
for obj in filtered:
self.check_object_permissions(self.request, obj)
return True
def check_object_permissions(self, request, obj):
if request.method != 'GET' and obj.creator != request.user:
self.permission_denied(
request, message={"detail": "Deleting other people's script is not allowed"}
request, message={"detail": _("Deleting other people's script is not allowed")}
)
return super().check_object_permissions(request, obj)

38
apps/ops/api/job.py

@ -22,6 +22,7 @@ from ops.models import Job, JobExecution
from ops.serializers.job import (
JobSerializer, JobExecutionSerializer, FileSerializer, JobTaskStopSerializer
)
from ops.utils import merge_nodes_and_assets
__all__ = [
'JobViewSet', 'JobExecutionViewSet', 'JobRunVariableHelpAPIView', 'JobExecutionTaskDetail', 'UsernameHintsAPI'
@ -36,8 +37,6 @@ from accounts.models import Account
from assets.const import Protocol
from perms.const import ActionChoices
from perms.utils.asset_perm import PermAssetDetailUtil
from perms.models import PermNode
from perms.utils import UserPermAssetUtil
from jumpserver.settings import get_file_md5
@ -47,26 +46,12 @@ def set_task_to_serializer_data(serializer, task_id):
setattr(serializer, "_data", data)
def merge_nodes_and_assets(nodes, assets, user):
if not nodes:
return assets
perm_util = UserPermAssetUtil(user=user)
for node_id in nodes:
if node_id == PermNode.FAVORITE_NODE_KEY:
node_assets = perm_util.get_favorite_assets()
elif node_id == PermNode.UNGROUPED_NODE_KEY:
node_assets = perm_util.get_ungroup_assets()
else:
_, node_assets = perm_util.get_node_all_assets(node_id)
assets.extend(node_assets.exclude(id__in=[asset.id for asset in assets]))
return assets
class JobViewSet(OrgBulkModelViewSet):
serializer_class = JobSerializer
filterset_fields = ('name', 'type')
search_fields = ('name', 'comment')
model = Job
_parameters = None
def check_permissions(self, request):
# job: upload_file
@ -106,10 +91,10 @@ class JobViewSet(OrgBulkModelViewSet):
def perform_create(self, serializer):
run_after_save = serializer.validated_data.pop('run_after_save', False)
node_ids = serializer.validated_data.pop('nodes', [])
assets = serializer.validated_data.get('assets')
assets = merge_nodes_and_assets(node_ids, assets, self.request.user)
serializer.validated_data['assets'] = assets
self._parameters = serializer.validated_data.pop('parameters', None)
nodes = serializer.validated_data.pop('nodes', [])
assets = serializer.validated_data.get('assets', [])
assets = merge_nodes_and_assets(nodes, assets, self.request.user)
if serializer.validated_data.get('type') == Types.upload_file:
account_name = serializer.validated_data.get('runas')
self.check_upload_permission(assets, account_name)
@ -120,12 +105,15 @@ class JobViewSet(OrgBulkModelViewSet):
def perform_update(self, serializer):
run_after_save = serializer.validated_data.pop('run_after_save', False)
self._parameters = serializer.validated_data.pop('parameters', None)
instance = serializer.save()
if run_after_save:
self.run_job(instance, serializer)
def run_job(self, job, serializer):
execution = job.create_execution()
if self._parameters:
execution.parameters = JobExecutionSerializer.validate_parameters(self._parameters)
execution.creator = self.request.user
execution.save()
@ -238,7 +226,11 @@ class JobExecutionViewSet(OrgBulkModelViewSet):
return Response({'error': serializer.errors}, status=400)
task_id = serializer.validated_data['task_id']
try:
instance = get_object_or_404(JobExecution, pk=task_id, creator=request.user)
user = request.user
if user.has_perm("audits.view_joblog"):
instance = get_object_or_404(JobExecution, task_id=task_id)
else:
instance = get_object_or_404(JobExecution, task_id=task_id, creator=request.user)
except Http404:
return Response(
{'error': _('The task is being created and cannot be interrupted. Please try again later.')},
@ -300,7 +292,7 @@ class UsernameHintsAPI(APIView):
permission_classes = [IsValidUser]
def post(self, request, **kwargs):
node_ids = request.data.get('nodes', None)
node_ids = request.data.get('nodes', [])
asset_ids = request.data.get('assets', [])
query = request.data.get('query', None)

7
apps/ops/api/playbook.py

@ -38,10 +38,15 @@ class PlaybookViewSet(JMSBulkModelViewSet):
search_fields = ('name', 'comment')
filterset_fields = ['scope', 'creator']
def allow_bulk_destroy(self, qs, filtered):
for obj in filtered:
self.check_object_permissions(self.request, obj)
return True
def check_object_permissions(self, request, obj):
if request.method != 'GET' and obj.creator != request.user:
self.permission_denied(
request, message={"detail": "Deleting other people's playbook is not allowed"}
request, message={"detail": _("Deleting other people's playbook is not allowed")}
)
return super().check_object_permissions(request, obj)

25
apps/ops/api/variable.py

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from rest_framework.decorators import action
from rest_framework.response import Response
from common.api.generic import JMSModelViewSet
from common.const.http import OPTIONS, GET
from common.permissions import IsValidUser
from ..models import Variable
from ..serializers import VariableSerializer, VariableFormDataSerializer
__all__ = [
'VariableViewSet'
]
class VariableViewSet(JMSModelViewSet):
queryset = Variable.objects.all()
serializer_class = VariableSerializer
http_method_names = ['options', 'get']
@action(methods=[GET], detail=False, serializer_class=VariableFormDataSerializer,
permission_classes=[IsValidUser, ], url_path='form_data')
def form_data(self, request, *args, **kwargs):
# 只是为了动态返回serializer fields info
return Response({})

5
apps/ops/const.py

@ -85,3 +85,8 @@ COMMAND_EXECUTION_DISABLED = _('Command execution disabled')
class Scope(models.TextChoices):
public = 'public', pgettext_lazy("scope", 'Public')
private = 'private', _('Private')
class FieldType(models.TextChoices):
text = 'text', _('Text')
select = 'select', _('Select')

28
apps/ops/migrations/0004_job_nodes_alter_job_assets.py

@ -0,0 +1,28 @@
# Generated by Django 4.1.13 on 2024-10-21 08:02
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('assets', '0006_database_pg_ssl_mode'),
('ops', '0003_alter_adhoc_unique_together_and_more'),
]
operations = [
migrations.AddField(
model_name='job',
name='nodes',
field=models.ManyToManyField(blank=True, to='assets.node', verbose_name='Node'),
),
migrations.AlterField(
model_name='job',
name='assets',
field=models.ManyToManyField(blank=True, to='assets.asset', verbose_name='Assets'),
),
migrations.AlterUniqueTogether(
name='job',
unique_together={('name', 'org_id', 'creator', 'type')},
),
]

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

Loading…
Cancel
Save