mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-24 21:12:35 +00:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eea34e6032 | ||
|
|
69a366978f | ||
|
|
fb634dca4c | ||
|
|
b045a64496 | ||
|
|
77e471022f | ||
|
|
25987545db | ||
|
|
f7313bfcc1 | ||
|
|
d2f7376f78 | ||
|
|
6db56eb2aa | ||
|
|
442290703a | ||
|
|
e491a724ed | ||
|
|
230924baac | ||
|
|
0ae2f04f28 | ||
|
|
68a490d305 | ||
|
|
6abfeee683 | ||
|
|
1a03f7b265 | ||
|
|
2dae2b3789 | ||
|
|
bdbbebab76 | ||
|
|
33170887f4 | ||
|
|
88302c8846 | ||
|
|
4068b5c76a | ||
|
|
9966ad4c71 | ||
|
|
9cfe974c52 | ||
|
|
d9a9f890f5 | ||
|
|
e2904ab042 | ||
|
|
f92c557235 | ||
|
|
cfadbc164c | ||
|
|
374a102bc4 | ||
|
|
84e1411c22 | ||
|
|
e28bf170d1 | ||
|
|
7c9e3a1362 | ||
|
|
fba80342a5 | ||
|
|
5eeff0aabf | ||
|
|
5b4de02fff | ||
|
|
b6a5854fa2 | ||
|
|
9771d3c817 | ||
|
|
b33a0cf0b1 | ||
|
|
f9fa6ad9c1 | ||
|
|
4b2db2b6a1 | ||
|
|
822b353a40 | ||
|
|
2908d4ee5f | ||
|
|
482c4ced0c | ||
|
|
b2a5e457a9 | ||
|
|
343c3607fa | ||
|
|
f03263eedf | ||
|
|
98d7ecbf3e | ||
|
|
477ccda8ca | ||
|
|
fcdc2b9510 | ||
|
|
1ee57cfda0 | ||
|
|
804bd289a4 | ||
|
|
86273865c8 | ||
|
|
5142f0340c | ||
|
|
7c80c52d02 | ||
|
|
eb30b61ca9 | ||
|
|
dd5a272cdf | ||
|
|
5b27acf4ef | ||
|
|
1a41a7450e | ||
|
|
e1b501c7d4 | ||
|
|
b660bfb7ff | ||
|
|
5724912480 | ||
|
|
11b3bafd5a | ||
|
|
9f90838df1 | ||
|
|
b01916001e | ||
|
|
c96ae1022b | ||
|
|
8f11167db0 | ||
|
|
a53397b76f | ||
|
|
8f13224454 | ||
|
|
8f4dd25e69 | ||
|
|
9c8762e3a0 | ||
|
|
a8cf788122 | ||
|
|
7355a4f152 | ||
|
|
2cf80e6615 | ||
|
|
9a18ed631c | ||
|
|
1e16f1cb9f | ||
|
|
35b8b080ab | ||
|
|
4219d54db3 | ||
|
|
c3620254b3 | ||
|
|
d30de0b6a0 | ||
|
|
af91b6faeb | ||
|
|
49b84b019d | ||
|
|
a0ee520572 | ||
|
|
972afe0bfe | ||
|
|
e47e9b0a11 | ||
|
|
87e54d8823 | ||
|
|
a73c8d8285 | ||
|
|
b0dd8d044d | ||
|
|
7c55c42582 | ||
|
|
cc1fcd2b98 | ||
|
|
8434d8d5ba | ||
|
|
044fd238b8 | ||
|
|
be096a1319 | ||
|
|
6fa14833b3 | ||
|
|
1f32ab274c | ||
|
|
6720ecc6e0 | ||
|
|
b0f86e43a6 | ||
|
|
9b0c81333f | ||
|
|
05fc966444 | ||
|
|
b87650038f | ||
|
|
d4f69a7ff8 | ||
|
|
0e1e26c29c | ||
|
|
1b8cdbc4dd | ||
|
|
2a781c228f | ||
|
|
35d6b0f16a | ||
|
|
ca8987fef6 | ||
|
|
b385133071 | ||
|
|
aa78a03efa | ||
|
|
31f8a19392 | ||
|
|
7a528b499a | ||
|
|
1c6ce422cf | ||
|
|
f9cf2ea2e5 | ||
|
|
575b3a617f | ||
|
|
b7362d3f51 | ||
|
|
6ee3860124 | ||
|
|
7e111da529 | ||
|
|
578458f734 | ||
|
|
bd56697d6d | ||
|
|
aad824d127 | ||
|
|
63f828da0b | ||
|
|
7c211b3fb6 | ||
|
|
3881edd2ba | ||
|
|
b882b12d04 | ||
|
|
addd2e7d1c | ||
|
|
ad6d2e1cd7 | ||
|
|
5f07271afa | ||
|
|
efdcd4c708 | ||
|
|
b62763bca3 | ||
|
|
e95da730f2 | ||
|
|
43fa3f420a | ||
|
|
0311446384 | ||
|
|
f7030e4fee | ||
|
|
fce8cc375f | ||
|
|
920199c6df | ||
|
|
d09eb3c4fa | ||
|
|
6e8affcdd6 |
24
.github/workflows/discord-release.yml
vendored
Normal file
24
.github/workflows/discord-release.yml
vendored
Normal file
@@ -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
vendored
Normal file
28
.github/workflows/llm-code-review.yml
vendored
Normal file
@@ -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"
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM jumpserver/core-base:20240919_024156 AS stage-build
|
||||
FROM jumpserver/core-base:20241105_025649 AS stage-build
|
||||
|
||||
ARG VERSION
|
||||
|
||||
@@ -28,6 +28,7 @@ ARG DEPENDENCIES=" \
|
||||
libx11-dev"
|
||||
|
||||
ARG TOOLS=" \
|
||||
cron \
|
||||
ca-certificates \
|
||||
default-libmysqlclient-dev \
|
||||
openssh-client \
|
||||
@@ -35,19 +36,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.const import AutomationTypes
|
||||
from assets.automations.ping_gateway.manager import PingGatewayManager
|
||||
from common.utils import get_logger
|
||||
@@ -13,7 +15,7 @@ class VerifyGatewayAccountManager(PingGatewayManager):
|
||||
|
||||
@staticmethod
|
||||
def before_runner_start():
|
||||
logger.info(">>> 开始执行测试网关账号可连接性任务")
|
||||
logger.info(_(">>> Start executing the task to test gateway account connectivity"))
|
||||
|
||||
def get_accounts(self, gateway):
|
||||
account_ids = self.execution.snapshot['accounts']
|
||||
|
||||
@@ -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
Normal file
1
apps/accounts/backends/azure/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .main import *
|
||||
70
apps/accounts/backends/azure/entries.py
Normal file
70
apps/accounts/backends/azure/entries.py
Normal file
@@ -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
Normal file
57
apps/accounts/backends/azure/main.py
Normal file
@@ -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
Normal file
59
apps/accounts/backends/azure/service.py
Normal file
@@ -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)}')
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, ''
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -7,3 +7,4 @@ __all__ = ['VaultTypeChoices']
|
||||
class VaultTypeChoices(models.TextChoices):
|
||||
local = 'local', _('Database')
|
||||
hcp = 'hcp', _('HCP Vault')
|
||||
azure = 'azure', _('Azure Key Vault')
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -53,7 +53,8 @@ class Account(AbsConnectivity, LabeledMixin, BaseAccount):
|
||||
on_delete=models.SET_NULL, verbose_name=_("Su from")
|
||||
)
|
||||
version = models.IntegerField(default=0, verbose_name=_('Version'))
|
||||
history = AccountHistoricalRecords(included_fields=['id', '_secret', 'secret_type', 'version'])
|
||||
history = AccountHistoricalRecords(included_fields=['id', '_secret', 'secret_type', 'version'],
|
||||
verbose_name=_("historical Account"))
|
||||
source = models.CharField(max_length=30, default=Source.LOCAL, verbose_name=_('Source'))
|
||||
source_id = models.CharField(max_length=128, null=True, blank=True, verbose_name=_('Source ID'))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ from .base import BaseAccountSerializer
|
||||
|
||||
|
||||
class PasswordRulesSerializer(serializers.Serializer):
|
||||
length = serializers.IntegerField(min_value=8, max_value=30, default=16, label=_('Password length'))
|
||||
length = serializers.IntegerField(min_value=8, max_value=36, default=16, label=_('Password length'))
|
||||
lowercase = serializers.BooleanField(default=True, label=_('Lowercase'))
|
||||
uppercase = serializers.BooleanField(default=True, label=_('Uppercase'))
|
||||
digit = serializers.BooleanField(default=True, label=_('Digit'))
|
||||
|
||||
@@ -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
|
||||
@@ -75,19 +95,6 @@ class ChangeSecretAutomationSerializer(AuthValidateMixin, BaseAutomationSerializ
|
||||
if self.initial_data.get('secret_strategy') == SecretStrategy.custom:
|
||||
return password_rules
|
||||
|
||||
length = password_rules.get('length')
|
||||
|
||||
try:
|
||||
length = int(length)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
msg = _("* Please enter the correct password length")
|
||||
raise serializers.ValidationError(msg)
|
||||
|
||||
if length < 6 or length > 30:
|
||||
msg = _('* Password length range 6-30 bits')
|
||||
raise serializers.ValidationError(msg)
|
||||
|
||||
return password_rules
|
||||
|
||||
def validate(self, attrs):
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -115,7 +115,7 @@ class PingGatewayManager:
|
||||
|
||||
@staticmethod
|
||||
def before_runner_start():
|
||||
print(">>> 开始执行测试网关可连接性任务")
|
||||
print(_(">>> Start executing the task to test gateway connectivity"))
|
||||
|
||||
def get_accounts(self, gateway):
|
||||
account = gateway.select_account
|
||||
|
||||
@@ -112,7 +112,7 @@ class BaseType(TextChoices):
|
||||
|
||||
@classmethod
|
||||
def get_choices(cls):
|
||||
if not settings.XPACK_ENABLED:
|
||||
if not settings.XPACK_LICENSE_IS_VALID:
|
||||
choices = [(tp.value, tp.label) for tp in cls.get_community_types()]
|
||||
else:
|
||||
choices = cls.choices
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -18,7 +18,7 @@ from common.serializers.fields import LabeledChoiceField
|
||||
from labels.models import Label
|
||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
from ...const import Category, AllTypes
|
||||
from ...models import Asset, Node, Platform, Protocol
|
||||
from ...models import Asset, Node, Platform, Protocol, Host, Device, Database, Cloud, Web, Custom
|
||||
|
||||
__all__ = [
|
||||
'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer',
|
||||
@@ -309,6 +309,17 @@ class AssetSerializer(BulkOrgResourceModelSerializer, ResourceLabelsMixin, Writa
|
||||
})
|
||||
return protocols_data_map.values()
|
||||
|
||||
def validate_platform(self, platform_data):
|
||||
check_models = {Host, Device, Database, Cloud, Web, Custom}
|
||||
if self.Meta.model not in check_models:
|
||||
return platform_data
|
||||
model_name = self.Meta.model.__name__.lower()
|
||||
if model_name != platform_data.category:
|
||||
raise serializers.ValidationError({
|
||||
'platform': f"Platform does not match: {platform_data.name}"
|
||||
})
|
||||
return platform_data
|
||||
|
||||
@staticmethod
|
||||
def update_account_su_from(accounts, include_su_from_accounts):
|
||||
if not include_su_from_accounts:
|
||||
|
||||
@@ -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
|
||||
@@ -146,7 +179,9 @@ class MyLoginLogViewSet(UserLoginCommonMixin, OrgReadonlyModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
qs = qs.filter(username=self.request.user.username)
|
||||
username = self.request.user.username
|
||||
q = Q(username=username) | Q(username__icontains=f'({username})')
|
||||
qs = qs.filter(q)
|
||||
return qs
|
||||
|
||||
|
||||
|
||||
@@ -74,6 +74,9 @@ class OperateLogStore(object):
|
||||
@classmethod
|
||||
def convert_diff_friendly(cls, op_log):
|
||||
diff_list = list()
|
||||
# 标记翻译字符串
|
||||
labels = _("labels")
|
||||
operate_log_id = _("operate_log_id")
|
||||
handler = cls._get_special_handler(op_log.resource_type)
|
||||
for k, v in op_log.diff.items():
|
||||
before_value, after_value = cls.split_value(v)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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'
|
||||
'FavoriteAsset', 'ChangeSecretRecord', 'AppProvider', 'Variable'
|
||||
}
|
||||
include_models = {'UserSession'}
|
||||
for i, app in enumerate(apps.get_models(), 1):
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -472,6 +472,7 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet):
|
||||
rbac_perms = {
|
||||
'create': 'authentication.add_superconnectiontoken',
|
||||
'renewal': 'authentication.add_superconnectiontoken',
|
||||
'check': 'authentication.view_superconnectiontoken',
|
||||
'get_secret_detail': 'authentication.view_superconnectiontokensecret',
|
||||
'get_applet_info': 'authentication.view_superconnectiontoken',
|
||||
'release_applet_account': 'authentication.view_superconnectiontoken',
|
||||
@@ -484,6 +485,28 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet):
|
||||
def get_user(self, serializer):
|
||||
return serializer.validated_data.get('user')
|
||||
|
||||
@action(methods=['GET'], detail=True, url_path='check')
|
||||
def check(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
data = {
|
||||
"detail": "OK",
|
||||
"code": "perm_ok",
|
||||
"expired": instance.is_expired
|
||||
}
|
||||
try:
|
||||
self._validate_perm(
|
||||
instance.user,
|
||||
instance.asset,
|
||||
instance.account,
|
||||
instance.protocol
|
||||
)
|
||||
except JMSException as e:
|
||||
data['code'] = e.detail.code
|
||||
data['detail'] = str(e.detail)
|
||||
return Response(data=data, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(data=data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(methods=['PATCH'], detail=False)
|
||||
def renewal(self, request, *args, **kwargs):
|
||||
from common.utils.timezone import as_current_tz
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
Normal file
57
apps/authentication/mfa/face.py
Normal file
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +2,7 @@ import base64
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, reverse, render
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
@@ -34,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:
|
||||
@@ -116,23 +117,43 @@ class ThirdPartyLoginMiddleware(mixins.AuthMixin):
|
||||
|
||||
|
||||
class SessionCookieMiddleware(MiddlewareMixin):
|
||||
USER_LOGIN_ENCRYPTION_KEY_PAIR = 'user_login_encryption_key_pair'
|
||||
|
||||
@staticmethod
|
||||
def set_cookie_public_key(request, response):
|
||||
def set_cookie_public_key(self, request, response):
|
||||
if request.path.startswith('/api'):
|
||||
return
|
||||
pub_key_name = settings.SESSION_RSA_PUBLIC_KEY_NAME
|
||||
public_key = request.session.get(pub_key_name)
|
||||
cookie_key = request.COOKIES.get(pub_key_name)
|
||||
if public_key and public_key == cookie_key:
|
||||
|
||||
session_public_key_name = settings.SESSION_RSA_PUBLIC_KEY_NAME
|
||||
session_private_key_name = settings.SESSION_RSA_PRIVATE_KEY_NAME
|
||||
|
||||
session_public_key = request.session.get(session_public_key_name)
|
||||
cookie_public_key = request.COOKIES.get(session_public_key_name)
|
||||
|
||||
if session_public_key and session_public_key == cookie_public_key:
|
||||
return
|
||||
|
||||
pri_key_name = settings.SESSION_RSA_PRIVATE_KEY_NAME
|
||||
private_key, public_key = gen_key_pair()
|
||||
private_key, public_key = self.get_key_pair()
|
||||
|
||||
public_key_decode = base64.b64encode(public_key.encode()).decode()
|
||||
request.session[pub_key_name] = public_key_decode
|
||||
request.session[pri_key_name] = private_key
|
||||
response.set_cookie(pub_key_name, public_key_decode)
|
||||
|
||||
request.session[session_public_key_name] = public_key_decode
|
||||
request.session[session_private_key_name] = private_key
|
||||
response.set_cookie(session_public_key_name, public_key_decode)
|
||||
|
||||
def get_key_pair(self):
|
||||
key_pair = cache.get(self.USER_LOGIN_ENCRYPTION_KEY_PAIR)
|
||||
if key_pair:
|
||||
return key_pair['private_key'], key_pair['public_key']
|
||||
|
||||
private_key, public_key = gen_key_pair()
|
||||
|
||||
key_pair = {
|
||||
'private_key': private_key,
|
||||
'public_key': public_key
|
||||
}
|
||||
cache.set(self.USER_LOGIN_ENCRYPTION_KEY_PAIR, key_pair, None)
|
||||
|
||||
return private_key, public_key
|
||||
|
||||
@staticmethod
|
||||
def set_cookie_session_prefix(request, response):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,10 +8,8 @@
|
||||
<p>
|
||||
<b>{% trans 'Username' %}:</b> {{ username }}<br>
|
||||
<b>{% trans 'Login Date' %}:</b> {{ time }}<br>
|
||||
<b>{% trans 'Login city' %}:</b> {{ city }}({{ ip }})
|
||||
<b>{% trans 'Login city' %}:</b> {{ city }}({{ ip }})<br>
|
||||
</p>
|
||||
|
||||
-
|
||||
<p>
|
||||
{% trans 'If you suspect that the login behavior is abnormal, please modify the account password in time.' %}
|
||||
</p>
|
||||
@@ -10,8 +10,7 @@
|
||||
{% trans 'Click here reset password' %}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
-
|
||||
<br>
|
||||
<p>
|
||||
{% trans 'This link is valid for 1 hour. After it expires' %}
|
||||
<a href="{{ forget_password_url }}?email={{ user.email }}">{% trans 'request new one' %}</a>
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
{% trans 'Your password has just been successfully updated' %}
|
||||
</p>
|
||||
<p>
|
||||
<b>{% trans 'IP' %}:</b> {{ ip_address }} <br/>
|
||||
<b>{% trans 'Browser' %}:</b> {{ browser }}
|
||||
<b>{% trans 'IP' %}:</b> {{ ip_address }} <br>
|
||||
<b>{% trans 'Browser' %}:</b> {{ browser }} <br>
|
||||
</p>
|
||||
-
|
||||
<p>
|
||||
{% trans 'If the password update was not initiated by you, your account may have security issues' %} <br/>
|
||||
{% trans 'If you have any questions, you can contact the administrator' %}
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
{% trans 'Your public key has just been successfully updated' %}
|
||||
</p>
|
||||
<p>
|
||||
<b>{% trans 'IP' %}:</b> {{ ip_address }} <br/>
|
||||
<b>{% trans 'Browser' %}:</b> {{ browser }}
|
||||
<b>{% trans 'IP' %}:</b> {{ ip_address }} <br>
|
||||
<b>{% trans 'Browser' %}:</b> {{ browser }} <br>
|
||||
</p>
|
||||
-
|
||||
<p>
|
||||
{% trans 'If the public key update was not initiated by you, your account may have security issues' %} <br/>
|
||||
{% trans 'If you have any questions, you can contact the administrator' %}
|
||||
|
||||
@@ -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 = "{{ request.get_full_path }}";
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,4 +1,4 @@
|
||||
from .utils import gen_key_pair, rsa_decrypt, rsa_encrypt
|
||||
from common.utils import gen_key_pair, rsa_decrypt, rsa_encrypt
|
||||
|
||||
|
||||
def test_rsa_encrypt_decrypt(message='test-password-$%^&*'):
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
Normal file
41
apps/common/storage/jms_storage/__init__.py
Normal file
@@ -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
Normal file
61
apps/common/storage/jms_storage/azure.py
Normal file
@@ -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
Normal file
51
apps/common/storage/jms_storage/base.py
Normal file
@@ -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
Normal file
68
apps/common/storage/jms_storage/ceph.py
Normal file
@@ -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
Normal file
116
apps/common/storage/jms_storage/ftp.py
Normal file
@@ -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
Normal file
50
apps/common/storage/jms_storage/jms.py
Normal file
@@ -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
Normal file
77
apps/common/storage/jms_storage/multi.py
Normal file
@@ -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
Normal file
70
apps/common/storage/jms_storage/obs.py
Normal file
@@ -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
Normal file
72
apps/common/storage/jms_storage/oss.py
Normal file
@@ -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
Normal file
74
apps/common/storage/jms_storage/s3.py
Normal file
@@ -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
Normal file
109
apps/common/storage/jms_storage/sftp.py
Normal file
@@ -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()
|
||||
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import time
|
||||
from itertools import chain
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
@@ -62,16 +64,17 @@ class SessionPartReplayStorageHandler(object):
|
||||
|
||||
# 保存到storage的路径
|
||||
target_path = os.path.join(default_storage.base_location, local_path)
|
||||
|
||||
target_tmp_path = target_path + f'.tmp{int(time.time())}'
|
||||
target_dir = os.path.dirname(target_path)
|
||||
if not os.path.isdir(target_dir):
|
||||
make_dirs(target_dir, exist_ok=True)
|
||||
|
||||
ok, err = storage.download(remote_path, target_path)
|
||||
ok, err = storage.download(remote_path, target_tmp_path)
|
||||
if not ok:
|
||||
msg = 'Failed download {} file: {}'.format(part_filename, err)
|
||||
logger.error(msg)
|
||||
return None, msg
|
||||
shutil.move(target_tmp_path, target_path)
|
||||
url = default_storage.url(local_path)
|
||||
return local_path, url
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -158,7 +158,10 @@ def is_uuid(seq):
|
||||
def get_request_ip(request):
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')
|
||||
if x_forwarded_for and x_forwarded_for[0]:
|
||||
login_ip = x_forwarded_for[0].split(":")[0]
|
||||
login_ip = x_forwarded_for[0]
|
||||
if login_ip.count(':') == 1:
|
||||
# format: ipv4:port (非标准格式的 X-Forwarded-For)
|
||||
login_ip = login_ip.split(":")[0]
|
||||
return login_ip
|
||||
|
||||
login_ip = request.META.get('REMOTE_ADDR', '')
|
||||
|
||||
@@ -47,6 +47,7 @@ class Subscription:
|
||||
self.ch = pb.ch
|
||||
self.sub = sub
|
||||
self.unsubscribed = False
|
||||
logger.info(f"Subscribed to channel: {sub}")
|
||||
|
||||
def _handle_msg(self, _next, error, complete):
|
||||
"""
|
||||
@@ -105,10 +106,11 @@ class Subscription:
|
||||
|
||||
def unsubscribe(self):
|
||||
self.unsubscribed = True
|
||||
logger.info(f"Unsubscribed from channel: {self.sub}")
|
||||
try:
|
||||
self.sub.close()
|
||||
except Exception as e:
|
||||
logger.debug('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))
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:860b4d38beff81667c64da41c026a7dd28c3c93a28ae61fefaa7c26875f35638
|
||||
size 73906864
|
||||
oid sha256:c5119fd8911a107a7112422ade326766fe3d9538ac15bca06e3c622191c84e18
|
||||
size 61086554
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -11,7 +11,7 @@ class BaseTranslateManager:
|
||||
SEPARATOR = "<SEP>"
|
||||
LANG_MAPPER = {
|
||||
'ja': 'Japanese',
|
||||
'zh_hant': 'Taiwan',
|
||||
'zh_hant': 'Traditional Chinese',
|
||||
# 'en': 'English',
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"CommandReviewMessage": "The command you entered requires verification before it can be executed. Would you like to initiate a review request?",
|
||||
"CommandReviewRejectBy": "Command review request has been rejected by %s",
|
||||
"CommandReviewTimeoutError": "Command review request has timed out",
|
||||
"CommandWarningDialogMessage": "The command you executed is risky and an alert notification will be sent to the administrator. Do you want to continue?",
|
||||
"Confirm": "Confirm",
|
||||
"ConnectError": "Error while fetching data",
|
||||
"ConnectSuccess": "Connected successfully",
|
||||
@@ -22,6 +23,7 @@
|
||||
"ErrorMessage": "Error message",
|
||||
"ExecuteError": "Error while executing",
|
||||
"ExecuteSuccess": "Executed successfully",
|
||||
"ExecutionCanceled": "Execution Canceled",
|
||||
"ExportALL": "Export all data",
|
||||
"ExportAll": "Export all",
|
||||
"ExportCurrent": "Export current page",
|
||||
@@ -42,6 +44,9 @@
|
||||
"OverMaxSessionTimeError": "Since this session has been active for more than %d hours, it has been closed",
|
||||
"ParseError": "Error while parsing",
|
||||
"PasteNotAllowed": "You are not allowed to paste, please contact the administrator to open it!",
|
||||
"PermissionAlreadyExpired": "Permission already expired",
|
||||
"PermissionExpiredDialogMessage": "Permission has expired, and the session will expire in ten minutes. Please contact the administrator promptly for renewal",
|
||||
"PermissionExpiredDialogTitle": "Permission expired",
|
||||
"PermissionsExpiredOn": "Permissions associated with this session expired on %s",
|
||||
"Properties": "Properties",
|
||||
"Refresh": "Refresh",
|
||||
@@ -67,8 +72,6 @@
|
||||
"Version": "Version",
|
||||
"ViewData": "View data",
|
||||
"WaitCommandReviewMessage": "The review request has been initiated, please wait for the review results",
|
||||
"initializingDatasourceFailedMessage": "Connection failed, please check if the database connection configuration is correct",
|
||||
"Warning": "Warning",
|
||||
"ExecutionCanceled": "Execution Canceled",
|
||||
"CommandWarningDialogMessage": "The command you executed is risky and an alert notification will be sent to the administrator. Do you want to continue?"
|
||||
"initializingDatasourceFailedMessage": "Connection failed, please check if the database connection configuration is correct"
|
||||
}
|
||||
@@ -44,6 +44,9 @@
|
||||
"OverMaxSessionTimeError": "このセッションの時間が%d時間を超えたため、閉じられました",
|
||||
"ParseError": "解析に失敗しました",
|
||||
"PasteNotAllowed": "貼り付けは許可されていません。管理者に連絡して権限を開いてください!",
|
||||
"PermissionAlreadyExpired": "権限が期限切れ",
|
||||
"PermissionExpiredDialogMessage": "権限が期限切れで、セッションは10分後に期限切れになります。管理者に連絡し、期限を延長してください。",
|
||||
"PermissionExpiredDialogTitle": "権限が期限切れ",
|
||||
"PermissionsExpiredOn": "このセッションに関連する権限は%sに期限切れです",
|
||||
"Properties": "プロパティ",
|
||||
"Refresh": "リフレッシュ",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"CommandReviewMessage": "您输入的命令需要复核后才可以执行, 是否发起复核请求?",
|
||||
"CommandReviewRejectBy": "命令复核被 %s 拒绝",
|
||||
"CommandReviewTimeoutError": "命令复核超时",
|
||||
"CommandWarningDialogMessage": "您执行的命令存在风险,告警通知将发送给管理员。是否继续?",
|
||||
"Confirm": "确认",
|
||||
"ConnectError": "连接失败",
|
||||
"ConnectSuccess": "连接成功",
|
||||
@@ -22,6 +23,7 @@
|
||||
"ErrorMessage": "错误消息",
|
||||
"ExecuteError": "执行失败",
|
||||
"ExecuteSuccess": "执行成功",
|
||||
"ExecutionCanceled": "执行已取消",
|
||||
"ExportALL": "导出所有数据",
|
||||
"ExportAll": "导出全部",
|
||||
"ExportCurrent": "导出当前页面",
|
||||
@@ -42,6 +44,9 @@
|
||||
"OverMaxSessionTimeError": "由于此会话时间大于 %d 小时,已经被关闭",
|
||||
"ParseError": "解析失败",
|
||||
"PasteNotAllowed": "不允许粘贴,请联系管理员开启权限!",
|
||||
"PermissionAlreadyExpired": "授权已过期",
|
||||
"PermissionExpiredDialogMessage": "授权已过期,会话将在十分钟后过期,请及时联系管理员续期",
|
||||
"PermissionExpiredDialogTitle": "授权已过期",
|
||||
"PermissionsExpiredOn": "此会话关联的权限已于 %s 过期",
|
||||
"Properties": "属性",
|
||||
"Refresh": "刷新",
|
||||
@@ -67,8 +72,6 @@
|
||||
"Version": "版本",
|
||||
"ViewData": "查看数据",
|
||||
"WaitCommandReviewMessage": "复核请求已发起, 请等待复核结果",
|
||||
"initializingDatasourceFailedMessage": "连接失败,请检查数据库连接配置是否正确",
|
||||
"Warning": "警告",
|
||||
"ExecutionCanceled": "执行已取消",
|
||||
"CommandWarningDialogMessage": "您执行的命令存在风险,告警通知将发送给管理员。是否继续?"
|
||||
"initializingDatasourceFailedMessage": "连接失败,请检查数据库连接配置是否正确"
|
||||
}
|
||||
@@ -44,6 +44,9 @@
|
||||
"OverMaxSessionTimeError": "由於此會話時間大於 %d 小時,已經被關閉",
|
||||
"ParseError": "解析失敗",
|
||||
"PasteNotAllowed": "不允許貼上,請聯絡管理員開啟權限!",
|
||||
"PermissionAlreadyExpired": "授權已過期",
|
||||
"PermissionExpiredDialogMessage": "授權已過期,對話將在十分鐘後過期,請及時聯繫管理員續期。",
|
||||
"PermissionExpiredDialogTitle": "授權已過期",
|
||||
"PermissionsExpiredOn": "此會話關聯的權限已於 %s 過期",
|
||||
"Properties": "屬性",
|
||||
"Refresh": "刷新",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"ActionPerm": "Actions",
|
||||
"Cancel": "Cancel",
|
||||
"Clone Connect": "Clone Connect",
|
||||
"Close All Tabs": "Close All Tabs",
|
||||
"Close Current Tab": "Close Current Tab",
|
||||
"Confirm": "Confirm",
|
||||
"ConfirmBtn": "Confirm",
|
||||
"Connect": "Connect",
|
||||
@@ -8,6 +11,7 @@
|
||||
"CopyShareURLSuccess": "Copy Share URL Success",
|
||||
"CreateLink": "Create Share Link",
|
||||
"CreateSuccess": "Success",
|
||||
"Custom Setting": "Custom Setting",
|
||||
"DownArrow": "Down arrow",
|
||||
"Download": "Download",
|
||||
"DownloadSuccess": "Download success",
|
||||
@@ -32,7 +36,10 @@
|
||||
"OnlineUsers": "Online Users",
|
||||
"Paste": "Paste",
|
||||
"PauseSession": "Pause Session",
|
||||
"PermissionExpired": "Permission expired",
|
||||
"PermissionValid": "Permission valid",
|
||||
"ReadOnly": "Read-Only",
|
||||
"Reconnect": "Reconnect",
|
||||
"Refresh": "Refresh",
|
||||
"Remove": "Remove",
|
||||
"RemoveShareUser": "You have been removed from the shared session.",
|
||||
@@ -63,9 +70,6 @@
|
||||
"WaitFileTransfer": "Wait file transfer to finish",
|
||||
"WebSocketClosed": "WebSocket closed",
|
||||
"Writable": "Writable",
|
||||
"Reconnect": "Reconnect",
|
||||
"Close Current Tab": "Close Current Tab",
|
||||
"Close All Tabs": "Close All Tabs",
|
||||
"Clone Connect": "Clone Connect",
|
||||
"Custom Setting": "Custom Setting"
|
||||
"UploadStart": "Upload start",
|
||||
"UploadEnd": "Upload completed, please wait for further processing"
|
||||
}
|
||||
@@ -36,6 +36,8 @@
|
||||
"OnlineUsers": "オンラインスタッフ",
|
||||
"Paste": "貼り付け",
|
||||
"PauseSession": "セッションを一時停止",
|
||||
"PermissionExpired": "許可が期限切れになりました",
|
||||
"PermissionValid": "権限は有効です",
|
||||
"ReadOnly": "読み取り専用",
|
||||
"Reconnect": "再接続",
|
||||
"Refresh": "リフレッシュ",
|
||||
@@ -67,5 +69,7 @@
|
||||
"VerifyCode": "認証コード",
|
||||
"WaitFileTransfer": "ファイル転送終了待ち",
|
||||
"WebSocketClosed": "WebSocket 閉店",
|
||||
"Writable": "書き込み可能"
|
||||
"Writable": "書き込み可能",
|
||||
"UploadStart": "アップロード開始",
|
||||
"UploadEnd": "アップロードが完了しました。後の処理をお待ちください"
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"ActionPerm": "操作权限",
|
||||
"Cancel": "取消",
|
||||
"Clone Connect": "复制窗口",
|
||||
"Close All Tabs": "关闭所有",
|
||||
"Close Current Tab": "关闭当前",
|
||||
"Confirm": "确认",
|
||||
"ConfirmBtn": "确定",
|
||||
"Connect": "连接",
|
||||
@@ -8,6 +11,7 @@
|
||||
"CopyShareURLSuccess": "复制分享地址成功",
|
||||
"CreateLink": "创建分享链接",
|
||||
"CreateSuccess": "创建成功",
|
||||
"Custom Setting": "自定义设置",
|
||||
"DownArrow": "向下箭头",
|
||||
"Download": "下载",
|
||||
"DownloadSuccess": "下载成功",
|
||||
@@ -32,7 +36,10 @@
|
||||
"OnlineUsers": "在线人员",
|
||||
"Paste": "粘贴",
|
||||
"PauseSession": "暂停此会话",
|
||||
"PermissionExpired": "权限已过期",
|
||||
"PermissionValid": "权限有效",
|
||||
"ReadOnly": "只读",
|
||||
"Reconnect": "重新连接",
|
||||
"Refresh": "刷新",
|
||||
"Remove": "移除",
|
||||
"RemoveShareUser": "你已经被移除共享会话",
|
||||
@@ -58,14 +65,11 @@
|
||||
"UploadSuccess": "上传成功",
|
||||
"UploadTips": "将文件拖到此处,或点击上传",
|
||||
"UploadTitle": "上传文件",
|
||||
"UploadStart": "上传开始",
|
||||
"UploadEnd": "上传已完成,请等待后续处理",
|
||||
"User": "用户",
|
||||
"VerifyCode": "验证码",
|
||||
"WaitFileTransfer": "等待文件传输结束",
|
||||
"WebSocketClosed": "WebSocket 已关闭",
|
||||
"Reconnect": "重新连接",
|
||||
"Writable": "可写",
|
||||
"Close Current Tab": "关闭当前",
|
||||
"Close All Tabs": "关闭所有",
|
||||
"Clone Connect": "复制窗口",
|
||||
"Custom Setting": "自定义设置"
|
||||
"Writable": "可写"
|
||||
}
|
||||
@@ -36,6 +36,8 @@
|
||||
"OnlineUsers": "在線人員",
|
||||
"Paste": "貼上",
|
||||
"PauseSession": "暫停此會話",
|
||||
"PermissionExpired": "權限已過期",
|
||||
"PermissionValid": "權限有效",
|
||||
"ReadOnly": "只讀",
|
||||
"Reconnect": "重新連線",
|
||||
"Refresh": "刷新",
|
||||
@@ -67,5 +69,7 @@
|
||||
"VerifyCode": "驗證碼",
|
||||
"WaitFileTransfer": "等待文件傳輸結束",
|
||||
"WebSocketClosed": "WebSocket 已關閉",
|
||||
"Writable": "讀寫"
|
||||
"Writable": "讀寫",
|
||||
"UploadStart": "上傳開始",
|
||||
"UploadEnd": "上傳已完成,請等待後續處理"
|
||||
}
|
||||
@@ -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",
|
||||
@@ -443,7 +448,6 @@
|
||||
"Docs": "Docs",
|
||||
"Download": "Download",
|
||||
"DownloadCenter": "Download",
|
||||
"DownloadFTPFileTip": "The current action does not record files, or the file size exceeds the threshold (default 100m), or it has not yet been saved to the corresponding storage",
|
||||
"DownloadImportTemplateMsg": "Download creation template",
|
||||
"DownloadReplay": "Download recording",
|
||||
"DownloadUpdateTemplateMsg": "Download update template",
|
||||
@@ -504,7 +508,11 @@
|
||||
"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)",
|
||||
"FTPUnknownStorageState": "Unknown file storage status, please contact your administrator.",
|
||||
"Failed": "Failed",
|
||||
"FailedAsset": "Failed assets",
|
||||
"FaviconTip": "Note: website icon (suggested image size: 16px*16px)",
|
||||
@@ -622,6 +630,7 @@
|
||||
"InputPhone": "Phone number",
|
||||
"InstanceAddress": "Instance address",
|
||||
"InstanceName": "Instance name",
|
||||
"InstanceNamePartIp": "Instance name and Partial IP",
|
||||
"InstancePlatformName": "Instance platform name",
|
||||
"Interface": "Appearance",
|
||||
"InterfaceSettings": "Appearance",
|
||||
@@ -644,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",
|
||||
@@ -671,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",
|
||||
@@ -816,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",
|
||||
@@ -896,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",
|
||||
@@ -1059,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",
|
||||
@@ -1200,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",
|
||||
@@ -1215,6 +1226,7 @@
|
||||
"TaskID": "Task id",
|
||||
"TaskList": "Tasks",
|
||||
"TaskMonitor": "Monitoring",
|
||||
"TaskPath": "Task path",
|
||||
"TechnologyConsult": "Technical consultation",
|
||||
"TempPasswordTip": "The temporary password is valid for 300 seconds and becomes invalid immediately after use",
|
||||
"TempToken": "Temporary tokens",
|
||||
@@ -1222,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",
|
||||
@@ -1350,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",
|
||||
@@ -1398,5 +1412,11 @@
|
||||
"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",
|
||||
"TaskPath": "Task path"
|
||||
"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"
|
||||
}
|
||||
@@ -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": "削除に失敗",
|
||||
@@ -458,7 +463,7 @@
|
||||
"Docs": "文書",
|
||||
"Download": "ダウンロード",
|
||||
"DownloadCenter": "ダウンロードセンター",
|
||||
"DownloadFTPFileTip": "現在のActionでは、ファイルは記録されず、またはファイルサイズが閾値(デフォルトは100M)を超える、またはまだ対応するストレージに保存されていない",
|
||||
"DownloadFTPFileTip": "現在のActionはファイルを記録せず、またはファイルのサイズが閾値(デフォルト100M)を超えている、またはまだ対応するストレージに保存されていません。",
|
||||
"DownloadImportTemplateMsg": "テンプレートをダウンロードで作成",
|
||||
"DownloadReplay": "ビデオのダウンロード",
|
||||
"DownloadUpdateTemplateMsg": "更新テンプレートをダウンロード",
|
||||
@@ -506,9 +511,10 @@
|
||||
"ExcludeSymbol": "文字の除外",
|
||||
"ExecCloudSyncErrorMsg": "クラウドアカウントの設定が完全でないので、更新して再試行してください",
|
||||
"Execute": "実行",
|
||||
"ExecuteAfterSaving": "保存後に実行",
|
||||
"ExecuteOnce": "一度実行する",
|
||||
"ExecutionDetail": "Action詳細",
|
||||
"ExecutionList": "実行リスト",
|
||||
"ExecutionList": "実行記録",
|
||||
"ExistError": "この要素は既に存在します",
|
||||
"Existing": "既に存在しています",
|
||||
"ExpirationTimeout": "有効期限タイムアウト(秒)",
|
||||
@@ -519,7 +525,12 @@
|
||||
"ExportOnlyFiltered": "検索結果のみをエクスポート",
|
||||
"ExportOnlySelectedItems": "選択オプションのみをエクスポート",
|
||||
"ExportRange": "エクスポート範囲",
|
||||
"ExtraArgsFormatError": "書式が間違っています。要件に従って入力してください",
|
||||
"ExtraArgsPlaceholder": "一行ごとに一つの選択肢を書く、例えば:\n選択肢1:値1\n選択肢2:値2\n",
|
||||
"FC": "Fusion Compute",
|
||||
"FTPFileNotStored": "ファイルはまだストレージに保存されていません、後で確認してください。",
|
||||
"FTPStorageNotEnabled": "ファイルストレージ機能が有効になっていません、設定ファイルを変更し、次の設定を追加してください:FTP_FILE_MAX_STORE=100(100M以下のファイルを保存可能)",
|
||||
"FTPUnknownStorageState": "不明なファイルストレージの状態、管理者にご連絡ください。",
|
||||
"Failed": "失敗",
|
||||
"FailedAsset": "失敗した資産",
|
||||
"FaviconTip": "ヒント:ウェブサイトのアイコン(推奨画像サイズ:16px*16px)",
|
||||
@@ -637,6 +648,7 @@
|
||||
"InputPhone": "携帯電話番号を入力してください",
|
||||
"InstanceAddress": "インスタンスのアドレス",
|
||||
"InstanceName": "インスタンス名",
|
||||
"InstanceNamePartIp": "インスタンス名と部分IP",
|
||||
"InstancePlatformName": "インスタンスプラットフォーム名",
|
||||
"Interface": "ネットワークインターフェース",
|
||||
"InterfaceSettings": "インターフェースの設定",
|
||||
@@ -664,9 +676,11 @@
|
||||
"JobCenter": "Actionセンター",
|
||||
"JobCreate": "ジョブ作成",
|
||||
"JobDetail": "作業詳細",
|
||||
"JobExecutionLog": "作業ログ",
|
||||
"JobManagement": "作業管理",
|
||||
"JobExecutionLog": "実行記録",
|
||||
"JobList": "作業リスト",
|
||||
"JobManagement": "作業列表",
|
||||
"JobUpdate": "アップデート作業",
|
||||
"JobsAudit": "作業の監査",
|
||||
"KingSoftCloud": "Kingsoftクラウド",
|
||||
"KokoSetting": "KoKo 設定",
|
||||
"LAN": "LAN",
|
||||
@@ -696,6 +710,8 @@
|
||||
"LicenseForTest": "テスト用ライセンス。このライセンスはテスト(PoC)とデモンストレーションにのみ使用されます",
|
||||
"LicenseReachedAssetAmountLimit": "資産の数量がライセンスの数量制限を超えています",
|
||||
"LicenseWillBe": "ライセンスは間もなく ",
|
||||
"ListPreference": "リスト設定",
|
||||
"LoadTemplate": "テンプレートからロード",
|
||||
"Loading": "読み込み中",
|
||||
"LockedIP": "IP {count} つがロックされました",
|
||||
"Log": "ログ",
|
||||
@@ -847,6 +863,7 @@
|
||||
"OperateLog": "操作ログ",
|
||||
"OperationLogNum": "Actionログ数",
|
||||
"Options": "オプション",
|
||||
"OracleDBNameHelpText": "Oracle データベースの SID またはサービス名 (サービス名) を入力します。",
|
||||
"OrgAdmin": "管理",
|
||||
"OrgAuditor": "組織監査員",
|
||||
"OrgName": "Actionグループの名前",
|
||||
@@ -1049,7 +1066,7 @@
|
||||
"Rules": "規則",
|
||||
"Run": "Action",
|
||||
"RunAgain": "再実行",
|
||||
"RunAs": "実行ユーザー",
|
||||
"RunAs": "実行アカウント (じっこうアカウント)",
|
||||
"RunCommand": "コマンドの実行",
|
||||
"RunJob": "ジョブを実行",
|
||||
"RunSucceed": "タスクが成功",
|
||||
@@ -1093,7 +1110,7 @@
|
||||
"Secure": "安全",
|
||||
"Security": "セキュリティ設定",
|
||||
"Select": "選択",
|
||||
"SelectAdhoc": "コマンドの選択",
|
||||
"SelectAdhoc": "スクリプトテンプレートを選択",
|
||||
"SelectAll": "全選択",
|
||||
"SelectAtLeastOneAssetOrNodeErrMsg": "アセットまたはノードは少なくとも一つ選択してください",
|
||||
"SelectAttrs": "属性の選択",
|
||||
@@ -1178,6 +1195,7 @@
|
||||
"StatusYellow": "最近、実行に失敗があり。",
|
||||
"Step": "ステップ",
|
||||
"Stop": "停止",
|
||||
"StopJobMsg": "成功を停止",
|
||||
"StopLogOutput": "ask Canceled:現在のタスク(currentTaskId)は手動で停止されました。各タスクの進行状況が異なるため、以下はタスクの最終実行結果です。実行が失敗した場合は、タスクが正常に停止されました。",
|
||||
"Storage": "ストレージ",
|
||||
"StorageSetting": "ストレージ設定",
|
||||
@@ -1240,7 +1258,6 @@
|
||||
"SystemTasks": "タスクリスト",
|
||||
"SystemTools": "システムツール",
|
||||
"TableColSetting": "表示属性列の選択",
|
||||
"TableSetting": "テーブル環境設定",
|
||||
"TagCreate": "ラベルの作成",
|
||||
"TagInputFormatValidation": "ラベルの形式が間違っています、正しい形式は:name:value",
|
||||
"TagList": "タグ一覧",
|
||||
@@ -1262,7 +1279,8 @@
|
||||
"TemplateAdd": "テンプレート追加",
|
||||
"TemplateCreate": "テンプレート作成",
|
||||
"TemplateHelpText": "テンプレートを選択して追加すると、資産の下に存在しないアカウントが自動的に作成され、プッシュされます",
|
||||
"TemplateManagement": "テンプレート管理",
|
||||
"TemplateManagement": "テンプレート一覧",
|
||||
"Templates": "テンプレート",
|
||||
"TencentCloud": "テンセントクラウド",
|
||||
"Terminal": "コンポーネント設定",
|
||||
"TerminalDetail": "コンポーネントの詳細",
|
||||
@@ -1295,6 +1313,7 @@
|
||||
"Timeout": "タイムアウト",
|
||||
"TimeoutHelpText": "この値が-1の場合、タイムアウト時間を指定しない",
|
||||
"Timer": "定期的にAction",
|
||||
"TimerExecution": "定期実行",
|
||||
"Title": "タイトル",
|
||||
"To": "至",
|
||||
"Today": "今日",
|
||||
@@ -1391,6 +1410,7 @@
|
||||
"Valid": "有効",
|
||||
"Variable": "変数",
|
||||
"VariableHelpText": "コマンド中で {{ key }} を使用して内蔵変数を読み取ることができます",
|
||||
"VariableName": "変数名",
|
||||
"VaultHCPMountPoint": "Vault サーバのマウントポイント、デフォルトはjumpserver",
|
||||
"VaultHelpText": "1. セキュリティ上の理由により、設定ファイルで Vault ストレージをオンにする必要があります。<br>2. オンにした後、他の設定を入力してテストを行います。<br>3. データ同期を行います。同期は一方向です。ローカルデータベースからリモートの Vault にのみ同期します。同期が終了すればローカルデータベースはパスワードを保管していませんので、データのバックアップをお願いします。<br>4. Vault の設定を二度変更した後はサービスを再起動する必要があります。",
|
||||
"VerificationCodeSent": "認証コードが送信されました",
|
||||
@@ -1438,5 +1458,6 @@
|
||||
"ZoneUpdate": "更新エリア",
|
||||
"disallowSelfUpdateFields": "現在のフィールドを自分で変更することは許可されていません",
|
||||
"forceEnableMFAHelpText": "強制的に有効化すると、ユーザーは自分で無効化することができません。",
|
||||
"removeWarningMsg": "削除してもよろしいですか"
|
||||
"removeWarningMsg": "削除してもよろしいですか",
|
||||
"setVariable": "パラメータ設定"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user