mirror of https://github.com/jumpserver/jumpserver
commit
f3acc28ded
|
@ -0,0 +1,24 @@
|
|||
name: Auto update docs changelog
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
update_docs_changelog:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.event.release.tag_name, 'v4.')
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Update docs changelog
|
||||
env:
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
DOCS_TOKEN: ${{ secrets.DOCS_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name 'BaiJiangJie'
|
||||
git config --global user.email 'jiangjie.bai@fit2cloud.com'
|
||||
|
||||
git clone https://$DOCS_TOKEN@github.com/jumpservice/documentation.git
|
||||
cd documentation/utils
|
||||
bash update_changelog.sh
|
|
@ -0,0 +1,40 @@
|
|||
name: Translate README
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_langs:
|
||||
description: "Target Languages"
|
||||
required: false
|
||||
default: "zh-hans,zh-hant,ja,pt-br"
|
||||
gen_dir_path:
|
||||
description: "Generate Dir Name"
|
||||
required: false
|
||||
default: "readmes/"
|
||||
push_branch:
|
||||
description: "Push Branch"
|
||||
required: false
|
||||
default: "pr@dev@translate_readme"
|
||||
prompt:
|
||||
description: "AI Translate Prompt"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
gpt_mode:
|
||||
description: "GPT Mode"
|
||||
required: false
|
||||
default: "gpt-4o-mini"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Auto Translate
|
||||
uses: jumpserver-dev/action-translate-readme@main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.GPT_API_TOKEN }}
|
||||
GPT_MODE: ${{ github.event.inputs.gpt_mode }}
|
||||
TARGET_LANGUAGES: ${{ github.event.inputs.target_langs }}
|
||||
PUSH_BRANCH: ${{ github.event.inputs.push_branch }}
|
||||
GEN_DIR_PATH: ${{ github.event.inputs.gen_dir_path }}
|
||||
PROMPT: ${{ github.event.inputs.prompt }}
|
|
@ -1,4 +1,4 @@
|
|||
FROM jumpserver/core-base:20241105_025649 AS stage-build
|
||||
FROM jumpserver/core-base:20241210_070105 AS stage-build
|
||||
|
||||
ARG VERSION
|
||||
|
||||
|
@ -24,6 +24,7 @@ ENV LANG=en_US.UTF-8 \
|
|||
PATH=/opt/py3/bin:$PATH
|
||||
|
||||
ARG DEPENDENCIES=" \
|
||||
libldap2-dev \
|
||||
libx11-dev"
|
||||
|
||||
ARG TOOLS=" \
|
||||
|
|
16
README.md
16
README.md
|
@ -10,7 +10,8 @@
|
|||
[![][github-release-shield]][github-release-link]
|
||||
[![][github-stars-shield]][github-stars-link]
|
||||
|
||||
**English** · [简体中文](./README.zh-CN.md)
|
||||
[English](/README.md) · [中文(简体)](/readmes/README.zh-hans.md) · [中文(繁體)](/readmes/README.zh-hant.md) · [日本語](/readmes/README.ja.md) · [Português (Brasil)](/readmes/README.pt-br.md)
|
||||
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
|
@ -68,10 +69,13 @@ JumpServer consists of multiple key components, which collectively form the func
|
|||
| [KoKo](https://github.com/jumpserver/koko) | <a href="https://github.com/jumpserver/koko/releases"><img alt="Koko release" src="https://img.shields.io/github/release/jumpserver/koko.svg" /></a> | JumpServer Character Protocol Connector |
|
||||
| [Lion](https://github.com/jumpserver/lion) | <a href="https://github.com/jumpserver/lion/releases"><img alt="Lion release" src="https://img.shields.io/github/release/jumpserver/lion.svg" /></a> | JumpServer Graphical Protocol Connector |
|
||||
| [Chen](https://github.com/jumpserver/chen) | <a href="https://github.com/jumpserver/chen/releases"><img alt="Chen release" src="https://img.shields.io/github/release/jumpserver/chen.svg" /> | JumpServer Web DB |
|
||||
| [Razor](https://github.com/jumpserver/razor) | <img alt="Chen" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE RDP Proxy Connector |
|
||||
| [Tinker](https://github.com/jumpserver/tinker) | <img alt="Tinker" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Windows) |
|
||||
| [Panda](https://github.com/jumpserver/Panda) | <img alt="Panda" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Linux) |
|
||||
| [Magnus](https://github.com/jumpserver/magnus) | <img alt="Magnus" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Database Proxy Connector |
|
||||
| [Razor](https://github.com/jumpserver/razor) | <img alt="Chen" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE RDP Proxy Connector |
|
||||
| [Tinker](https://github.com/jumpserver/tinker) | <img alt="Tinker" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Windows) |
|
||||
| [Panda](https://github.com/jumpserver/Panda) | <img alt="Panda" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Remote Application Connector (Linux) |
|
||||
| [Magnus](https://github.com/jumpserver/magnus) | <img alt="Magnus" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Database Proxy Connector |
|
||||
| [Nec](https://github.com/jumpserver/nec) | <img alt="Nec" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE VNC Proxy Connector |
|
||||
| [Facelive](https://github.com/jumpserver/facelive) | <img alt="Facelive" src="https://img.shields.io/badge/release-private-red" /> | JumpServer EE Facial Recognition |
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
|
@ -85,7 +89,7 @@ JumpServer is a mission critical product. Please refer to the Basic Security Rec
|
|||
|
||||
## License
|
||||
|
||||
Copyright (c) 2014-2024 飞致云 FIT2CLOUD, All rights reserved.
|
||||
Copyright (c) 2014-2024 FIT2CLOUD, All rights reserved.
|
||||
|
||||
Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
|
||||
|
||||
|
|
|
@ -109,7 +109,7 @@ JumpServer是一款安全产品,请参考 [基本安全建议](https://docs.ju
|
|||
|
||||
## License & Copyright
|
||||
|
||||
Copyright (c) 2014-2024 飞致云 FIT2CLOUD, All rights reserved.
|
||||
Copyright (c) 2014-2024 飞致云, All rights reserved.
|
||||
|
||||
Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in
|
||||
compliance with the License. You may obtain a copy of the License at
|
||||
|
|
|
@ -160,6 +160,10 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
|||
ChangeSecretRecord.objects.bulk_create(records)
|
||||
return inventory_hosts
|
||||
|
||||
@staticmethod
|
||||
def require_update_version(account, recorder):
|
||||
return account.secret != recorder.new_secret
|
||||
|
||||
def on_host_success(self, host, result):
|
||||
recorder = self.name_recorder_mapper.get(host)
|
||||
if not recorder:
|
||||
|
@ -171,6 +175,8 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
|||
if not account:
|
||||
print("Account not found, deleted ?")
|
||||
return
|
||||
|
||||
version_update_required = self.require_update_version(account, recorder)
|
||||
account.secret = recorder.new_secret
|
||||
account.date_updated = timezone.now()
|
||||
|
||||
|
@ -180,7 +186,10 @@ class ChangeSecretManager(AccountBasePlaybookManager):
|
|||
while retry_count < max_retries:
|
||||
try:
|
||||
recorder.save()
|
||||
account.save(update_fields=['secret', 'version', 'date_updated'])
|
||||
account_update_fields = ['secret', 'date_updated']
|
||||
if version_update_required:
|
||||
account_update_fields.append('version')
|
||||
account.save(update_fields=account_update_fields)
|
||||
break
|
||||
except Exception as e:
|
||||
retry_count += 1
|
||||
|
|
|
@ -8,6 +8,11 @@ logger = get_logger(__name__)
|
|||
|
||||
class PushAccountManager(ChangeSecretManager, AccountBasePlaybookManager):
|
||||
|
||||
@staticmethod
|
||||
def require_update_version(account, recorder):
|
||||
account.skip_history_when_saving = True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def method_type(cls):
|
||||
return AutomationTypes.push_account
|
||||
|
|
|
@ -13,9 +13,6 @@ logger = get_logger(__file__)
|
|||
def get_vault_client(raise_exception=False, **kwargs):
|
||||
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)
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
from .main import *
|
|
@ -0,0 +1,16 @@
|
|||
from .service import AmazonSecretsManagerClient
|
||||
from ..base.vault import BaseVault
|
||||
from ..utils.mixins import GeneralVaultMixin
|
||||
from ...const import VaultTypeChoices
|
||||
|
||||
|
||||
class Vault(GeneralVaultMixin, BaseVault):
|
||||
type = VaultTypeChoices.aws
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.client = AmazonSecretsManagerClient(
|
||||
region_name=kwargs.get('VAULT_AWS_REGION_NAME'),
|
||||
access_key_id=kwargs.get('VAULT_AWS_ACCESS_KEY_ID'),
|
||||
secret_key=kwargs.get('VAULT_AWS_ACCESS_SECRET_KEY'),
|
||||
)
|
|
@ -0,0 +1,56 @@
|
|||
import boto3
|
||||
|
||||
from common.utils import get_logger, random_string
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ['AmazonSecretsManagerClient']
|
||||
|
||||
|
||||
class AmazonSecretsManagerClient(object):
|
||||
def __init__(self, region_name, access_key_id, secret_key):
|
||||
self.client = boto3.client(
|
||||
'secretsmanager', region_name=region_name,
|
||||
aws_access_key_id=access_key_id, aws_secret_access_key=secret_key,
|
||||
)
|
||||
self.empty_secret = '#{empty}#'
|
||||
|
||||
def is_active(self):
|
||||
try:
|
||||
secret_id = f'jumpserver/test-{random_string(12)}'
|
||||
self.create(secret_id, 'secret')
|
||||
self.get(secret_id)
|
||||
self.update(secret_id, 'secret')
|
||||
self.delete(secret_id)
|
||||
except Exception as e:
|
||||
return False, f'Vault is not reachable: {e}'
|
||||
else:
|
||||
return True, ''
|
||||
|
||||
def get(self, name, version=''):
|
||||
params = {'SecretId': name}
|
||||
if version:
|
||||
params['VersionStage'] = version
|
||||
|
||||
try:
|
||||
secret = self.client.get_secret_value(**params)['SecretString']
|
||||
return secret if secret != self.empty_secret else ''
|
||||
except Exception: # noqa
|
||||
return ''
|
||||
|
||||
def create(self, name, secret):
|
||||
self.client.create_secret(Name=name, SecretString=secret or self.empty_secret)
|
||||
|
||||
def update(self, name, secret):
|
||||
self.client.update_secret(SecretId=name, SecretString=secret or self.empty_secret)
|
||||
|
||||
def delete(self, name):
|
||||
self.client.delete_secret(SecretId=name)
|
||||
|
||||
def update_metadata(self, name, metadata: dict):
|
||||
tags = [{'Key': k, 'Value': v} for k, v in metadata.items()]
|
||||
try:
|
||||
self.client.tag_resource(SecretId=name, Tags=tags)
|
||||
except Exception as e:
|
||||
logger.error(f'update_metadata: {name} {str(e)}')
|
|
@ -1,41 +1,13 @@
|
|||
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']
|
||||
from ..base.entries import BaseEntry
|
||||
|
||||
|
||||
class BaseEntry(ABC):
|
||||
|
||||
def __init__(self, instance):
|
||||
self.instance = instance
|
||||
|
||||
@lazyproperty
|
||||
class AzureBaseEntry(BaseEntry):
|
||||
@property
|
||||
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):
|
||||
class AccountEntry(AzureBaseEntry):
|
||||
|
||||
@property
|
||||
def path_spec(self):
|
||||
|
@ -45,7 +17,7 @@ class AccountEntry(BaseEntry):
|
|||
return path
|
||||
|
||||
|
||||
class AccountTemplateEntry(BaseEntry):
|
||||
class AccountTemplateEntry(AzureBaseEntry):
|
||||
|
||||
@property
|
||||
def path_spec(self):
|
||||
|
@ -53,18 +25,9 @@ class AccountTemplateEntry(BaseEntry):
|
|||
return path
|
||||
|
||||
|
||||
class HistoricalAccountEntry(BaseEntry):
|
||||
class HistoricalAccountEntry(AzureBaseEntry):
|
||||
|
||||
@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)
|
||||
|
|
|
@ -1,16 +1,10 @@
|
|||
from common.db.utils import get_logger
|
||||
from .entries import build_entry
|
||||
from .service import AZUREVaultClient
|
||||
from ..base import BaseVault
|
||||
|
||||
from ..base.vault import BaseVault
|
||||
from ..utils.mixins import GeneralVaultMixin
|
||||
from ...const import VaultTypeChoices
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ['Vault']
|
||||
|
||||
|
||||
class Vault(BaseVault):
|
||||
class Vault(GeneralVaultMixin, BaseVault):
|
||||
type = VaultTypeChoices.azure
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -21,37 +15,3 @@ class Vault(BaseVault):
|
|||
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}')
|
||||
|
|
|
@ -36,7 +36,6 @@ class AZUREVaultClient(object):
|
|||
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):
|
||||
|
|
|
@ -1,76 +0,0 @@
|
|||
from abc import ABC, abstractmethod
|
||||
|
||||
from django.forms.models import model_to_dict
|
||||
|
||||
__all__ = ['BaseVault']
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def create(self, instance):
|
||||
if not instance.secret_has_save_to_vault:
|
||||
self._create(instance)
|
||||
self._clean_db_secret(instance)
|
||||
self.save_metadata(instance)
|
||||
|
||||
def update(self, instance):
|
||||
if not instance.secret_has_save_to_vault:
|
||||
self._update(instance)
|
||||
self._clean_db_secret(instance)
|
||||
self.save_metadata(instance)
|
||||
|
||||
if instance.is_sync_metadata:
|
||||
self.save_metadata(instance)
|
||||
|
||||
def delete(self, instance):
|
||||
self._delete(instance)
|
||||
|
||||
def save_metadata(self, instance):
|
||||
metadata = model_to_dict(instance, fields=[
|
||||
'name', 'username', 'secret_type',
|
||||
'connectivity', 'su_from', 'privileged'
|
||||
])
|
||||
metadata = {k: str(v)[:500] for k, v in metadata.items() if v}
|
||||
return self._save_metadata(instance, metadata)
|
||||
|
||||
# -------- abstractmethod -------- #
|
||||
|
||||
@abstractmethod
|
||||
def _get(self, instance):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _create(self, instance):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _update(self, instance):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _delete(self, instance):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _clean_db_secret(self, instance):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _save_metadata(self, instance, metadata):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def is_active(self, *args, **kwargs) -> (bool, str):
|
||||
raise NotImplementedError
|
|
@ -1,19 +1,18 @@
|
|||
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
|
||||
|
||||
@property
|
||||
def path_base(self):
|
||||
path = f'orgs/{self.instance.org_id}'
|
||||
return path
|
||||
|
||||
@lazyproperty
|
||||
def full_path(self):
|
||||
path_base = self.path_base
|
||||
|
@ -21,32 +20,24 @@ class BaseEntry(ABC):
|
|||
path = f'{path_base}/{path_spec}'
|
||||
return path
|
||||
|
||||
@property
|
||||
def path_base(self):
|
||||
path = f'orgs/{self.instance.org_id}'
|
||||
return path
|
||||
|
||||
@property
|
||||
def path_spec(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def to_internal_data(self):
|
||||
def get_encrypt_secret(self):
|
||||
secret = getattr(self.instance, '_secret', None)
|
||||
if secret is not None:
|
||||
secret = Encryptor(secret).encrypt()
|
||||
data = {'secret': secret}
|
||||
return data
|
||||
return secret
|
||||
|
||||
@staticmethod
|
||||
def to_external_data(data):
|
||||
secret = data.pop('secret', None)
|
||||
def get_decrypt_secret(secret):
|
||||
if secret is not None:
|
||||
secret = Encryptor(secret).decrypt()
|
||||
return secret
|
||||
|
||||
|
||||
class AccountEntry(BaseEntry):
|
||||
|
||||
@property
|
||||
def path_spec(self):
|
||||
path = f'assets/{self.instance.asset_id}/accounts/{self.instance.id}'
|
||||
|
@ -54,7 +45,6 @@ class AccountEntry(BaseEntry):
|
|||
|
||||
|
||||
class AccountTemplateEntry(BaseEntry):
|
||||
|
||||
@property
|
||||
def path_spec(self):
|
||||
path = f'account-templates/{self.instance.id}'
|
||||
|
@ -62,23 +52,12 @@ class AccountTemplateEntry(BaseEntry):
|
|||
|
||||
|
||||
class HistoricalAccountEntry(BaseEntry):
|
||||
|
||||
@property
|
||||
def path_base(self):
|
||||
account = self.instance.instance
|
||||
path = f'accounts/{account.id}/'
|
||||
path = f'accounts/{self.instance.instance.id}'
|
||||
return path
|
||||
|
||||
@property
|
||||
def path_spec(self):
|
||||
path = f'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)
|
|
@ -0,0 +1,109 @@
|
|||
import importlib
|
||||
import inspect
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from django.forms.models import model_to_dict
|
||||
|
||||
from .entries import BaseEntry
|
||||
from ...const import VaultTypeChoices
|
||||
|
||||
|
||||
class BaseVault(ABC):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.enabled = kwargs.get('VAULT_ENABLED')
|
||||
self._entry_classes = {}
|
||||
self._load_entries()
|
||||
|
||||
def _load_entries_import_module(self, module_name):
|
||||
module = importlib.import_module(module_name)
|
||||
for name, obj in inspect.getmembers(module, inspect.isclass):
|
||||
self._entry_classes.setdefault(name, obj)
|
||||
|
||||
def _load_entries(self):
|
||||
if self.type == VaultTypeChoices.local:
|
||||
return
|
||||
|
||||
module_name = f'accounts.backends.{self.type}.entries'
|
||||
if importlib.util.find_spec(module_name): # noqa
|
||||
self._load_entries_import_module(module_name)
|
||||
base_module = 'accounts.backends.base.entries'
|
||||
self._load_entries_import_module(base_module)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def type(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def get(self, instance):
|
||||
""" 返回 secret 值 """
|
||||
return self._get(self.build_entry(instance))
|
||||
|
||||
def create(self, instance):
|
||||
if not instance.secret_has_save_to_vault:
|
||||
entry = self.build_entry(instance)
|
||||
self._create(entry)
|
||||
self._clean_db_secret(instance)
|
||||
self.save_metadata(entry)
|
||||
|
||||
def update(self, instance):
|
||||
entry = self.build_entry(instance)
|
||||
if not instance.secret_has_save_to_vault:
|
||||
self._update(entry)
|
||||
self._clean_db_secret(instance)
|
||||
self.save_metadata(entry)
|
||||
|
||||
if instance.is_sync_metadata:
|
||||
self.save_metadata(entry)
|
||||
|
||||
def delete(self, instance):
|
||||
entry = self.build_entry(instance)
|
||||
self._delete(entry)
|
||||
|
||||
def save_metadata(self, entry):
|
||||
metadata = model_to_dict(entry.instance, fields=[
|
||||
'name', 'username', 'secret_type',
|
||||
'connectivity', 'su_from', 'privileged'
|
||||
])
|
||||
metadata = {k: str(v)[:500] for k, v in metadata.items() if v}
|
||||
return self._save_metadata(entry, metadata)
|
||||
|
||||
def build_entry(self, instance):
|
||||
if self.type == VaultTypeChoices.local:
|
||||
return BaseEntry(instance)
|
||||
|
||||
entry_class_name = f'{instance.__class__.__name__}Entry'
|
||||
entry_class = self._entry_classes.get(entry_class_name)
|
||||
if not entry_class:
|
||||
raise Exception(f'Entry class {entry_class_name} is not found')
|
||||
return entry_class(instance)
|
||||
|
||||
def _clean_db_secret(self, instance):
|
||||
instance.is_sync_metadata = False
|
||||
instance.mark_secret_save_to_vault()
|
||||
|
||||
# -------- abstractmethod -------- #
|
||||
|
||||
@abstractmethod
|
||||
def _get(self, instance):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _create(self, entry):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _update(self, entry):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _delete(self, entry):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def _save_metadata(self, instance, metadata):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def is_active(self, *args, **kwargs) -> (bool, str):
|
||||
raise NotImplementedError
|
|
@ -1,10 +1,10 @@
|
|||
from common.db.utils import get_logger
|
||||
from .entries import build_entry
|
||||
from .service import VaultKVClient
|
||||
from ..base import BaseVault
|
||||
from ..base.vault import BaseVault
|
||||
|
||||
from ...const import VaultTypeChoices
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ['Vault']
|
||||
|
@ -24,34 +24,25 @@ class Vault(BaseVault):
|
|||
def is_active(self):
|
||||
return self.client.is_active()
|
||||
|
||||
def _get(self, instance):
|
||||
entry = build_entry(instance)
|
||||
def _get(self, entry):
|
||||
# TODO: get data 是不是层数太多了
|
||||
data = self.client.get(path=entry.full_path).get('data', {})
|
||||
data = entry.to_external_data(data)
|
||||
data = entry.get_decrypt_secret(data.get('secret'))
|
||||
return data
|
||||
|
||||
def _create(self, instance):
|
||||
entry = build_entry(instance)
|
||||
data = entry.to_internal_data()
|
||||
def _create(self, entry):
|
||||
data = {'secret': entry.get_encrypt_secret()}
|
||||
self.client.create(path=entry.full_path, data=data)
|
||||
|
||||
def _update(self, instance):
|
||||
entry = build_entry(instance)
|
||||
data = entry.to_internal_data()
|
||||
def _update(self, entry):
|
||||
data = {'secret': entry.get_encrypt_secret()}
|
||||
self.client.patch(path=entry.full_path, data=data)
|
||||
|
||||
def _delete(self, instance):
|
||||
entry = build_entry(instance)
|
||||
def _delete(self, entry):
|
||||
self.client.delete(path=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):
|
||||
def _save_metadata(self, entry, metadata):
|
||||
try:
|
||||
entry = build_entry(instance)
|
||||
self.client.update_metadata(path=entry.full_path, metadata=metadata)
|
||||
except Exception as e:
|
||||
logger.error(f'save metadata error: {e}')
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from common.utils import get_logger
|
||||
from ..base import BaseVault
|
||||
from ..base.vault import BaseVault
|
||||
from ...const import VaultTypeChoices
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
@ -13,23 +13,23 @@ class Vault(BaseVault):
|
|||
def is_active(self):
|
||||
return True, ''
|
||||
|
||||
def _get(self, instance):
|
||||
secret = getattr(instance, '_secret', None)
|
||||
def _get(self, entry):
|
||||
secret = getattr(entry.instance, '_secret', None)
|
||||
return secret
|
||||
|
||||
def _create(self, instance):
|
||||
def _create(self, entry):
|
||||
""" Ignore """
|
||||
pass
|
||||
|
||||
def _update(self, instance):
|
||||
def _update(self, entry):
|
||||
""" Ignore """
|
||||
pass
|
||||
|
||||
def _delete(self, instance):
|
||||
def _delete(self, entry):
|
||||
""" Ignore """
|
||||
pass
|
||||
|
||||
def _save_metadata(self, instance, metadata):
|
||||
def _save_metadata(self, entry, metadata):
|
||||
""" Ignore """
|
||||
pass
|
||||
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
from common.utils import get_logger
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GeneralVaultMixin(object):
|
||||
client = None
|
||||
|
||||
def is_active(self):
|
||||
return self.client.is_active()
|
||||
|
||||
def _get(self, entry):
|
||||
secret = self.client.get(name=entry.full_path)
|
||||
return entry.get_decrypt_secret(secret)
|
||||
|
||||
def _create(self, entry):
|
||||
secret = entry.get_encrypt_secret()
|
||||
self.client.create(name=entry.full_path, secret=secret)
|
||||
|
||||
def _update(self, entry):
|
||||
secret = entry.get_encrypt_secret()
|
||||
self.client.update(name=entry.full_path, secret=secret)
|
||||
|
||||
def _delete(self, entry):
|
||||
self.client.delete(name=entry.full_path)
|
||||
|
||||
def _save_metadata(self, entry, metadata):
|
||||
try:
|
||||
self.client.update_metadata(name=entry.full_path, metadata=metadata)
|
||||
except Exception as e:
|
||||
logger.error(f'save metadata error: {e}')
|
|
@ -8,3 +8,4 @@ class VaultTypeChoices(models.TextChoices):
|
|||
local = 'local', _('Database')
|
||||
hcp = 'hcp', _('HCP Vault')
|
||||
azure = 'azure', _('Azure Key Vault')
|
||||
aws = 'aws', _('Amazon Secrets Manager')
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
from common.exceptions import JMSException
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class VaultException(JMSException):
|
||||
default_detail = _(
|
||||
'Vault operation failed. Please retry or check your account information on Vault.'
|
||||
)
|
|
@ -14,13 +14,17 @@ from common.db import fields
|
|||
from common.db.encoder import ModelJSONFieldEncoder
|
||||
from common.utils import get_logger, lazyproperty
|
||||
from ops.mixin import PeriodTaskModelMixin
|
||||
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel
|
||||
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel, OrgManager
|
||||
|
||||
__all__ = ['AccountBackupAutomation', 'AccountBackupExecution']
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class BaseBackupAutomationManager(OrgManager):
|
||||
pass
|
||||
|
||||
|
||||
class AccountBackupAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
||||
types = models.JSONField(default=list)
|
||||
backup_type = models.CharField(max_length=128, choices=AccountBackupType.choices,
|
||||
|
@ -47,6 +51,8 @@ class AccountBackupAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
|||
max_length=4096, blank=True, null=True, verbose_name=_('Zip encrypt password')
|
||||
)
|
||||
|
||||
objects = BaseBackupAutomationManager.from_queryset(models.QuerySet)()
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}({self.org_id})'
|
||||
|
||||
|
|
|
@ -80,6 +80,7 @@ class VaultModelMixin(models.Model):
|
|||
|
||||
def mark_secret_save_to_vault(self):
|
||||
self._secret = self._secret_save_to_vault_mark
|
||||
self.skip_history_when_saving = True
|
||||
self.save()
|
||||
|
||||
@property
|
||||
|
|
|
@ -385,7 +385,7 @@ class AssetAccountBulkSerializer(
|
|||
|
||||
_results = {}
|
||||
for asset in assets:
|
||||
if asset not in secret_type_supports:
|
||||
if asset not in secret_type_supports and asset.category != Category.CUSTOM:
|
||||
_results[asset] = {
|
||||
'error': _('Asset does not support this secret type: %s') % secret_type,
|
||||
'state': 'error',
|
||||
|
|
|
@ -14,6 +14,7 @@ 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 .exceptions import VaultException
|
||||
from .models import Account, AccountTemplate
|
||||
from .tasks.push_account import push_accounts_to_assets_task
|
||||
|
||||
|
@ -22,6 +23,9 @@ logger = get_logger(__name__)
|
|||
|
||||
@receiver(pre_save, sender=Account)
|
||||
def on_account_pre_save(sender, instance, **kwargs):
|
||||
if getattr(instance, 'skip_history_when_saving', False):
|
||||
return
|
||||
|
||||
if instance.version == 0:
|
||||
instance.version = 1
|
||||
else:
|
||||
|
@ -65,7 +69,7 @@ def create_accounts_activities(account, action='create'):
|
|||
|
||||
@receiver(post_save, sender=Account)
|
||||
def on_account_create_by_template(sender, instance, created=False, **kwargs):
|
||||
if not created or instance.source != Source.TEMPLATE:
|
||||
if not created:
|
||||
return
|
||||
push_accounts_if_need.delay(accounts=(instance,))
|
||||
create_accounts_activities(instance, action='create')
|
||||
|
@ -81,14 +85,22 @@ class VaultSignalHandler(object):
|
|||
|
||||
@staticmethod
|
||||
def save_to_vault(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
vault_client.create(instance)
|
||||
else:
|
||||
vault_client.update(instance)
|
||||
try:
|
||||
if created:
|
||||
vault_client.create(instance)
|
||||
else:
|
||||
vault_client.update(instance)
|
||||
except Exception as e:
|
||||
logger.error('Vault save failed: {}'.format(e))
|
||||
raise VaultException()
|
||||
|
||||
@staticmethod
|
||||
def delete_to_vault(sender, instance, **kwargs):
|
||||
vault_client.delete(instance)
|
||||
try:
|
||||
vault_client.delete(instance)
|
||||
except Exception as e:
|
||||
logger.error('Vault delete failed: {}'.format(e))
|
||||
raise VaultException()
|
||||
|
||||
|
||||
for model in (Account, AccountTemplate, Account.history.model):
|
||||
|
|
|
@ -52,7 +52,8 @@ def sync_secret_to_vault():
|
|||
for model in to_sync_models:
|
||||
instances += list(model.objects.all())
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
max_workers = 1 if VaultTypeChoices.azure == vault_client.type else 10
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
tasks = [executor.submit(sync_instance, instance) for instance in instances]
|
||||
|
||||
for future in as_completed(tasks):
|
||||
|
|
|
@ -9,3 +9,5 @@ class ActionChoices(models.TextChoices):
|
|||
warning = 'warning', _('Warn')
|
||||
notice = 'notice', _('Notify')
|
||||
notify_and_warn = 'notify_and_warn', _('Notify and warn')
|
||||
face_verify = 'face_verify', _('Face Verify')
|
||||
face_online = 'face_online', _('Face Online')
|
||||
|
|
|
@ -32,7 +32,9 @@ class CommandFilterACLSerializer(BaseSerializer, BulkOrgResourceModelSerializer)
|
|||
class Meta(BaseSerializer.Meta):
|
||||
model = CommandFilterACL
|
||||
fields = BaseSerializer.Meta.fields + ['command_groups']
|
||||
action_choices_exclude = [ActionChoices.notice]
|
||||
action_choices_exclude = [ActionChoices.notice,
|
||||
ActionChoices.face_verify,
|
||||
ActionChoices.face_online]
|
||||
|
||||
|
||||
class CommandReviewSerializer(serializers.Serializer):
|
||||
|
|
|
@ -4,6 +4,7 @@ from common.serializers import MethodSerializer
|
|||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
from .base import BaseUserACLSerializer
|
||||
from .rules import RuleSerializer
|
||||
from ..const import ActionChoices
|
||||
from ..models import LoginACL
|
||||
|
||||
__all__ = ["LoginACLSerializer"]
|
||||
|
@ -17,6 +18,7 @@ class LoginACLSerializer(BaseUserACLSerializer, BulkOrgResourceModelSerializer):
|
|||
class Meta(BaseUserACLSerializer.Meta):
|
||||
model = LoginACL
|
||||
fields = BaseUserACLSerializer.Meta.fields + ['rules', ]
|
||||
action_choices_exclude = [ActionChoices.face_online, ActionChoices.face_verify]
|
||||
|
||||
def get_rules_serializer(self):
|
||||
return RuleSerializer()
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
from django.db.models import Count
|
||||
from django.db.models import Subquery, OuterRef, Count, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django_filters import rest_framework as filters
|
||||
from rest_framework import generics
|
||||
from rest_framework import serializers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from assets.const import AllTypes
|
||||
from assets.models import Platform, Node, Asset, PlatformProtocol
|
||||
from assets.serializers import PlatformSerializer, PlatformProtocolSerializer, PlatformListSerializer
|
||||
|
@ -42,7 +42,10 @@ class AssetPlatformViewSet(JMSModelViewSet):
|
|||
|
||||
def get_queryset(self):
|
||||
# 因为没有走分页逻辑,所以需要这里 prefetch
|
||||
queryset = super().get_queryset().annotate(assets_amount=Count('assets')).prefetch_related(
|
||||
asset_count_subquery = Asset.objects.filter(platform=OuterRef('pk')).values('platform').annotate(
|
||||
count=Count('id')).values('count')
|
||||
queryset = super().get_queryset().annotate(
|
||||
assets_amount=Coalesce(Subquery(asset_count_subquery), Value(0))).prefetch_related(
|
||||
'protocols', 'automation', 'labels', 'labels__label'
|
||||
)
|
||||
queryset = queryset.filter(type__in=AllTypes.get_types_values())
|
||||
|
|
|
@ -10,7 +10,11 @@ from assets.tasks import execute_asset_automation_task
|
|||
from common.const.choices import Trigger
|
||||
from common.db.fields import EncryptJsonDictTextField
|
||||
from ops.mixin import PeriodTaskModelMixin
|
||||
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel
|
||||
from orgs.mixins.models import OrgModelMixin, JMSOrgBaseModel, OrgManager
|
||||
|
||||
|
||||
class BaseAutomationManager(OrgManager):
|
||||
pass
|
||||
|
||||
|
||||
class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
||||
|
@ -21,6 +25,8 @@ class BaseAutomation(PeriodTaskModelMixin, JMSOrgBaseModel):
|
|||
is_active = models.BooleanField(default=True, verbose_name=_("Is active"))
|
||||
params = models.JSONField(default=dict, verbose_name=_("Parameters"))
|
||||
|
||||
objects = BaseAutomationManager.from_queryset(models.QuerySet)()
|
||||
|
||||
def __str__(self):
|
||||
return self.name + '@' + str(self.created_by)
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ class DomainSerializer(ResourceLabelsMixin, BulkOrgResourceModelSerializer):
|
|||
model = Domain
|
||||
fields_mini = ['id', 'name']
|
||||
fields_small = fields_mini + ['comment']
|
||||
fields_m2m = ['assets', 'gateways', 'assets_amount']
|
||||
fields_m2m = ['assets', 'gateways', 'labels', 'assets_amount']
|
||||
read_only_fields = ['date_created']
|
||||
fields = fields_small + fields_m2m + read_only_fields
|
||||
extra_kwargs = {
|
||||
|
|
|
@ -222,9 +222,13 @@ class ResourceActivityAPIView(generics.ListAPIView):
|
|||
'id', 'datetime', 'r_detail', 'r_detail_id',
|
||||
'r_user', 'r_action', 'r_type'
|
||||
)
|
||||
org_q = Q(org_id=Organization.SYSTEM_ID) | Q(org_id=current_org.id)
|
||||
if resource_id:
|
||||
org_q |= Q(org_id='') | Q(org_id=Organization.ROOT_ID)
|
||||
|
||||
org_q = Q()
|
||||
if not current_org.is_root():
|
||||
org_q = Q(org_id=Organization.SYSTEM_ID) | Q(org_id=current_org.id)
|
||||
if resource_id:
|
||||
org_q |= Q(org_id='') | Q(org_id=Organization.ROOT_ID)
|
||||
|
||||
with tmp_to_root_org():
|
||||
qs1 = self.get_operate_log_qs(fields, limit, org_q, resource_id=resource_id)
|
||||
qs2 = self.get_activity_log_qs(fields, limit, org_q, resource_id=resource_id)
|
||||
|
|
|
@ -14,7 +14,7 @@ from audits.handler import (
|
|||
create_or_update_operate_log, get_instance_dict_from_cache
|
||||
)
|
||||
from audits.utils import model_to_dict_for_operate_log as model_to_dict
|
||||
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR, SKIP_SIGNAL
|
||||
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR, OP_LOG_SKIP_SIGNAL
|
||||
from common.signals import django_ready
|
||||
from jumpserver.utils import current_request
|
||||
from ..const import MODELS_NEED_RECORD, ActionChoices
|
||||
|
@ -77,7 +77,7 @@ def signal_of_operate_log_whether_continue(
|
|||
condition = True
|
||||
if not instance:
|
||||
condition = False
|
||||
if instance and getattr(instance, SKIP_SIGNAL, False):
|
||||
if instance and getattr(instance, OP_LOG_SKIP_SIGNAL, False):
|
||||
condition = False
|
||||
# 不记录组件的操作日志
|
||||
user = current_request.user if current_request else None
|
||||
|
|
|
@ -14,7 +14,7 @@ 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.JobLogAuditViewSet, 'job-log')
|
||||
router.register(r'jobs', api.JobsAuditViewSet, 'jobs')
|
||||
router.register(r'jobs', api.JobsAuditViewSet, 'job')
|
||||
|
||||
router.register(r'my-login-logs', api.MyLoginLogViewSet, 'my-login-log')
|
||||
router.register(r'user-sessions', api.UserSessionViewSet, 'user-session')
|
||||
|
|
|
@ -15,3 +15,4 @@ from .ssh_key import *
|
|||
from .sso import *
|
||||
from .temp_token import *
|
||||
from .token import *
|
||||
from .face import *
|
||||
|
|
|
@ -24,11 +24,13 @@ from common.utils.http import is_true, is_false
|
|||
from orgs.mixins.api import RootOrgViewMixin
|
||||
from orgs.utils import tmp_to_org
|
||||
from perms.models import ActionChoices
|
||||
from terminal.connect_methods import NativeClient, ConnectMethodUtil
|
||||
from terminal.connect_methods import NativeClient, ConnectMethodUtil, WebMethod
|
||||
from terminal.models import EndpointRule, Endpoint
|
||||
from users.const import FileNameConflictResolution
|
||||
from users.const import RDPSmartSize, RDPColorQuality
|
||||
from users.models import Preference
|
||||
from .face import FaceMonitorContext
|
||||
from ..mixins import AuthFaceMixin
|
||||
from ..models import ConnectionToken, date_expired_default
|
||||
from ..serializers import (
|
||||
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
|
||||
|
@ -67,6 +69,36 @@ class RDPFileClientProtocolURLMixin:
|
|||
'bookmarktype:i': '3',
|
||||
'use redirection server name:i': '0',
|
||||
}
|
||||
|
||||
# copy from
|
||||
# https://learn.microsoft.com/zh-cn/windows-server/administration/performance-tuning/role/remote-desktop/session-hosts
|
||||
rdp_low_speed_broadband_option = {
|
||||
"connection type:i": 2,
|
||||
"disable wallpaper:i": 1,
|
||||
"bitmapcachepersistenable:i": 1,
|
||||
"disable full window drag:i": 1,
|
||||
"disable menu anims:i": 1,
|
||||
"allow font smoothing:i": 0,
|
||||
"allow desktop composition:i": 0,
|
||||
"disable themes:i": 0
|
||||
}
|
||||
|
||||
rdp_high_speed_broadband_option = {
|
||||
"connection type:i": 4,
|
||||
"disable wallpaper:i": 0,
|
||||
"bitmapcachepersistenable:i": 1,
|
||||
"disable full window drag:i": 1,
|
||||
"disable menu anims:i": 0,
|
||||
"allow font smoothing:i": 0,
|
||||
"allow desktop composition:i": 1,
|
||||
"disable themes:i": 0
|
||||
}
|
||||
|
||||
RDP_CONNECTION_SPEED_OPTION_MAP = {
|
||||
"auto": {},
|
||||
"low_speed_broadband": rdp_low_speed_broadband_option,
|
||||
"high_speed_broadband": rdp_high_speed_broadband_option,
|
||||
}
|
||||
# 设置多屏显示
|
||||
multi_mon = is_true(self.request.query_params.get('multi_mon'))
|
||||
if multi_mon:
|
||||
|
@ -115,6 +147,8 @@ class RDPFileClientProtocolURLMixin:
|
|||
rdp = token.asset.platform.protocols.filter(name='rdp').first()
|
||||
if rdp and rdp.setting.get('console'):
|
||||
rdp_options['administrative session:i'] = '1'
|
||||
rdp_connection_speed = token.connect_options.get('rdp_connection_speed', 'auto')
|
||||
rdp_options.update(RDP_CONNECTION_SPEED_OPTION_MAP.get(rdp_connection_speed, {}))
|
||||
|
||||
# 文件名
|
||||
name = token.asset.name
|
||||
|
@ -221,6 +255,8 @@ class ExtraActionApiMixin(RDPFileClientProtocolURLMixin):
|
|||
get_serializer: callable
|
||||
perform_create: callable
|
||||
validate_exchange_token: callable
|
||||
need_face_verify: bool
|
||||
create_face_verify: callable
|
||||
|
||||
@action(methods=['POST', 'GET'], detail=True, url_path='rdp-file')
|
||||
def get_rdp_file(self, request, *args, **kwargs):
|
||||
|
@ -280,10 +316,13 @@ class ExtraActionApiMixin(RDPFileClientProtocolURLMixin):
|
|||
instance.date_expired = date_expired_default()
|
||||
instance.save()
|
||||
serializer = self.get_serializer(instance)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
response = Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
if self.need_face_verify:
|
||||
self.create_face_verify(response)
|
||||
return response
|
||||
|
||||
|
||||
class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelViewSet):
|
||||
class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixin, JMSModelViewSet):
|
||||
filterset_fields = (
|
||||
'user_display', 'asset_display'
|
||||
)
|
||||
|
@ -304,6 +343,8 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||
'get_client_protocol_url': 'authentication.add_connectiontoken',
|
||||
}
|
||||
input_username = ''
|
||||
need_face_verify = False
|
||||
face_monitor_token = ''
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = ConnectionToken.objects \
|
||||
|
@ -355,8 +396,9 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||
asset = data.get('asset')
|
||||
account_name = data.get('account')
|
||||
protocol = data.get('protocol')
|
||||
connect_method = data.get('connect_method')
|
||||
self.input_username = self.get_input_username(data)
|
||||
_data = self._validate(user, asset, account_name, protocol)
|
||||
_data = self._validate(user, asset, account_name, protocol, connect_method)
|
||||
data.update(_data)
|
||||
return serializer
|
||||
|
||||
|
@ -364,12 +406,12 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||
user = token.user
|
||||
asset = token.asset
|
||||
account_name = token.account
|
||||
_data = self._validate(user, asset, account_name, token.protocol)
|
||||
_data = self._validate(user, asset, account_name, token.protocol, token.connect_method)
|
||||
for k, v in _data.items():
|
||||
setattr(token, k, v)
|
||||
return token
|
||||
|
||||
def _validate(self, user, asset, account_name, protocol):
|
||||
def _validate(self, user, asset, account_name, protocol, connect_method):
|
||||
data = dict()
|
||||
data['org_id'] = asset.org_id
|
||||
data['user'] = user
|
||||
|
@ -385,10 +427,16 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||
if account.username != AliasAccount.INPUT:
|
||||
data['input_username'] = ''
|
||||
|
||||
ticket = self._validate_acl(user, asset, account)
|
||||
ticket = self._validate_acl(user, asset, account, connect_method)
|
||||
if ticket:
|
||||
data['from_ticket'] = ticket
|
||||
|
||||
if ticket or self.need_face_verify:
|
||||
data['is_active'] = False
|
||||
if self.face_monitor_token:
|
||||
FaceMonitorContext.get_or_create_context(self.face_monitor_token,
|
||||
self.request.user.id)
|
||||
data['face_monitor_token'] = self.face_monitor_token
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
|
@ -417,7 +465,7 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||
after=after, object_name=object_name
|
||||
)
|
||||
|
||||
def _validate_acl(self, user, asset, account):
|
||||
def _validate_acl(self, user, asset, account, connect_method):
|
||||
from acls.models import LoginAssetACL
|
||||
kwargs = {'user': user, 'asset': asset, 'account': account}
|
||||
if account.username == AliasAccount.INPUT:
|
||||
|
@ -444,6 +492,26 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||
assignees=acl.reviewers.all(), org_id=asset.org_id
|
||||
)
|
||||
return ticket
|
||||
if acl.is_action(acl.ActionChoices.face_verify):
|
||||
if not self.request.query_params.get('face_verify'):
|
||||
msg = _('ACL action is face verify')
|
||||
raise JMSException(code='acl_face_verify', detail=msg)
|
||||
self.need_face_verify = True
|
||||
if acl.is_action(acl.ActionChoices.face_online):
|
||||
if connect_method not in [WebMethod.web_cli, WebMethod.web_gui]:
|
||||
msg = _('ACL action not supported for this asset')
|
||||
raise JMSException(detail=msg, code='acl_face_online_not_supported')
|
||||
|
||||
face_verify = self.request.query_params.get('face_verify')
|
||||
face_monitor_token = self.request.query_params.get('face_monitor_token')
|
||||
|
||||
if not face_verify or not face_monitor_token:
|
||||
msg = _('ACL action is face online')
|
||||
raise JMSException(code='acl_face_online', detail=msg)
|
||||
|
||||
self.need_face_verify = True
|
||||
self.face_monitor_token = face_monitor_token
|
||||
|
||||
if acl.is_action(acl.ActionChoices.notice):
|
||||
reviewers = acl.reviewers.all()
|
||||
if not reviewers:
|
||||
|
@ -455,9 +523,22 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
|
|||
reviewer, asset, user, account, self.input_username
|
||||
).publish_async()
|
||||
|
||||
def create_face_verify(self, response):
|
||||
if not self.request.user.face_vector:
|
||||
raise JMSException(code='no_face_feature', detail=_('No available face feature'))
|
||||
connection_token_id = response.data.get('id')
|
||||
context_data = {
|
||||
"action": "login_asset",
|
||||
"connection_token_id": connection_token_id,
|
||||
}
|
||||
face_verify_token = self.create_face_verify_context(context_data)
|
||||
response.data['face_token'] = face_verify_token
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
try:
|
||||
response = super().create(request, *args, **kwargs)
|
||||
if self.need_face_verify:
|
||||
self.create_face_verify(response)
|
||||
except JMSException as e:
|
||||
data = {'code': e.detail.code, 'detail': e.detail}
|
||||
return Response(data, status=e.status_code)
|
||||
|
|
|
@ -0,0 +1,256 @@
|
|||
from django.core.cache import cache
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.generics import CreateAPIView, RetrieveAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.exceptions import NotFound
|
||||
|
||||
from common.permissions import IsServiceAccount
|
||||
from common.utils import get_logger, get_object_or_none
|
||||
from orgs.utils import tmp_to_root_org
|
||||
from terminal.api.session.task import create_sessions_tasks
|
||||
from users.models import User
|
||||
|
||||
from .. import serializers
|
||||
from ..mixins import AuthMixin
|
||||
from ..const import FACE_CONTEXT_CACHE_KEY_PREFIX, FACE_SESSION_KEY, FACE_CONTEXT_CACHE_TTL, FaceMonitorActionChoices
|
||||
from ..models import ConnectionToken
|
||||
from ..serializers.face import FaceMonitorCallbackSerializer, FaceMonitorContextSerializer
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = [
|
||||
'FaceCallbackApi',
|
||||
'FaceContextApi',
|
||||
'FaceMonitorContext',
|
||||
'FaceMonitorContextApi',
|
||||
'FaceMonitorCallbackApi'
|
||||
]
|
||||
|
||||
|
||||
class FaceCallbackApi(AuthMixin, CreateAPIView):
|
||||
permission_classes = (IsServiceAccount,)
|
||||
serializer_class = serializers.FaceCallbackSerializer
|
||||
|
||||
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'"})
|
||||
try:
|
||||
self._handle_success(context, face_code)
|
||||
except Exception as e:
|
||||
self._update_context_with_error(context, str(e))
|
||||
return Response(status=200)
|
||||
|
||||
@staticmethod
|
||||
def get_face_cache_key(token):
|
||||
return f"{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, FACE_CONTEXT_CACHE_TTL)
|
||||
|
||||
def _handle_success(self, context, face_code):
|
||||
context.update({
|
||||
'is_finished': True,
|
||||
'success': True,
|
||||
'face_code': face_code
|
||||
})
|
||||
action = context.get('action', None)
|
||||
if action == 'login_asset':
|
||||
user_id = context.get('user_id')
|
||||
user = User.objects.get(id=user_id)
|
||||
|
||||
if user.check_face(face_code):
|
||||
with tmp_to_root_org():
|
||||
connection_token_id = context.get('connection_token_id')
|
||||
token = ConnectionToken.objects.filter(id=connection_token_id).first()
|
||||
token.is_active = True
|
||||
token.save()
|
||||
else:
|
||||
context.update({
|
||||
'success': False,
|
||||
'error_message': _('Facial comparison failed')
|
||||
})
|
||||
self._update_cache(context)
|
||||
|
||||
|
||||
class FaceContextApi(AuthMixin, RetrieveAPIView, CreateAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
face_token_session_key = FACE_SESSION_KEY
|
||||
|
||||
@staticmethod
|
||||
def get_face_cache_key(token):
|
||||
return f"{FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
|
||||
|
||||
def new_face_context(self):
|
||||
return self.create_face_verify_context()
|
||||
|
||||
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(self.face_token_session_key)
|
||||
|
||||
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", ''))
|
||||
})
|
||||
|
||||
|
||||
class FaceMonitorContext:
|
||||
def __init__(self, token, user_id, session_ids=None):
|
||||
self.token = token
|
||||
self.user_id = user_id
|
||||
if session_ids is None:
|
||||
self.session_ids = []
|
||||
else:
|
||||
self.session_ids = session_ids
|
||||
|
||||
@classmethod
|
||||
def get_cache_key(cls, token):
|
||||
return 'FACE_MONITOR_CONTEXT_{}'.format(token)
|
||||
|
||||
@classmethod
|
||||
def get_or_create_context(cls, token, user_id):
|
||||
context = cls.get(token)
|
||||
if not context:
|
||||
context = FaceMonitorContext(token=token,
|
||||
user_id=user_id)
|
||||
context.save()
|
||||
return context
|
||||
|
||||
def add_session(self, session_id):
|
||||
self.session_ids.append(session_id)
|
||||
self.save()
|
||||
|
||||
@classmethod
|
||||
def get(cls, token):
|
||||
cache_key = cls.get_cache_key(token)
|
||||
return cache.get(cache_key, None)
|
||||
|
||||
def save(self):
|
||||
cache_key = self.get_cache_key(self.token)
|
||||
cache.set(cache_key, self)
|
||||
|
||||
def close(self):
|
||||
self.terminal_sessions()
|
||||
self._destroy()
|
||||
|
||||
def _destroy(self):
|
||||
cache_key = self.get_cache_key(self.token)
|
||||
cache.delete(cache_key)
|
||||
|
||||
def pause_sessions(self):
|
||||
self._send_task('lock_session')
|
||||
|
||||
def resume_sessions(self):
|
||||
self._send_task('unlock_session')
|
||||
|
||||
def terminal_sessions(self):
|
||||
self._send_task("kill_session")
|
||||
|
||||
def _send_task(self, task_name):
|
||||
create_sessions_tasks(self.session_ids, 'facelive', task_name=task_name)
|
||||
|
||||
|
||||
class FaceMonitorContextApi(CreateAPIView):
|
||||
permission_classes = (IsServiceAccount,)
|
||||
serializer_class = FaceMonitorContextSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
face_monitor_token = serializer.validated_data.get('face_monitor_token')
|
||||
session_id = serializer.validated_data.get('session_id')
|
||||
|
||||
context = FaceMonitorContext.get(face_monitor_token)
|
||||
context.add_session(session_id)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
return Response(status=201)
|
||||
|
||||
|
||||
class FaceMonitorCallbackApi(CreateAPIView):
|
||||
permission_classes = (IsServiceAccount,)
|
||||
serializer_class = FaceMonitorCallbackSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
token = serializer.validated_data.get('token')
|
||||
|
||||
context = FaceMonitorContext.get(token=token)
|
||||
is_finished = serializer.validated_data.get('is_finished')
|
||||
if is_finished:
|
||||
context.close()
|
||||
return Response(status=200)
|
||||
|
||||
action = serializer.validated_data.get('action')
|
||||
if action == FaceMonitorActionChoices.Verify:
|
||||
user = get_object_or_none(User, pk=context.user_id)
|
||||
face_codes = serializer.validated_data.get('face_codes')
|
||||
|
||||
if not user:
|
||||
context.save()
|
||||
return Response(data={'msg': 'user {} not found'
|
||||
.format(context.user_id)}, status=400)
|
||||
|
||||
if not face_codes or not self._check_face_codes(face_codes, user):
|
||||
context.save()
|
||||
return Response(data={'msg': 'face codes not matched'}, status=400)
|
||||
|
||||
if action == FaceMonitorActionChoices.Pause:
|
||||
context.pause_sessions()
|
||||
if action == FaceMonitorActionChoices.Resume:
|
||||
context.resume_sessions()
|
||||
|
||||
context.save()
|
||||
return Response(status=200)
|
||||
|
||||
@staticmethod
|
||||
def _check_face_codes(face_codes, user):
|
||||
matched = False
|
||||
for face_code in face_codes:
|
||||
matched = user.check_face(face_code,
|
||||
distance_threshold=0.45,
|
||||
similarity_threshold=0.92)
|
||||
if matched:
|
||||
break
|
||||
return matched
|
|
@ -1,8 +1,5 @@
|
|||
# -*- 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
|
||||
|
@ -10,15 +7,12 @@ 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
|
||||
|
||||
|
@ -26,101 +20,9 @@ logger = get_logger(__name__)
|
|||
|
||||
__all__ = [
|
||||
'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 原来的名字
|
||||
|
|
|
@ -23,10 +23,9 @@ class JMSBaseAuthBackend:
|
|||
Reject users with is_valid=False. Custom user models that don't have
|
||||
that attribute are allowed.
|
||||
"""
|
||||
# 在 check_user_auth 中进行了校验,可以返回对应的错误信息
|
||||
# is_valid = getattr(user, 'is_valid', None)
|
||||
# return is_valid or is_valid is None
|
||||
return True
|
||||
# 三方用户认证完成后,在后续的 get_user 获取逻辑中,也应该需要检查用户是否有效
|
||||
is_valid = getattr(user, 'is_valid', None)
|
||||
return is_valid or is_valid is None
|
||||
|
||||
# allow user to authenticate
|
||||
def username_allow_authenticate(self, username):
|
||||
|
@ -52,6 +51,14 @@ class JMSBaseAuthBackend:
|
|||
logger.info(info)
|
||||
return allow
|
||||
|
||||
def get_user(self, user_id):
|
||||
""" 三方用户认证成功后 request.user 赋值时会调用 backend 的当前方法获取用户 """
|
||||
try:
|
||||
user = UserModel._default_manager.get(pk=user_id)
|
||||
except UserModel.DoesNotExist:
|
||||
return None
|
||||
return user if self.user_can_authenticate(user) else None
|
||||
|
||||
|
||||
class JMSModelBackend(JMSBaseAuthBackend, ModelBackend):
|
||||
pass
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.urls import path
|
||||
import django_cas_ng.views
|
||||
from django.urls import path
|
||||
|
||||
from .views import CASLoginView
|
||||
from .views import CASLoginView, CASCallbackClientView
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', CASLoginView.as_view(), name='cas-login'),
|
||||
path('logout/', django_cas_ng.views.LogoutView.as_view(), name='cas-logout'),
|
||||
path('callback/', django_cas_ng.views.CallbackView.as_view(), name='cas-proxy-callback'),
|
||||
path('login/client', CASCallbackClientView.as_view(), name='cas-proxy-callback-client'),
|
||||
]
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
from django_cas_ng.views import LoginView
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views.generic import View
|
||||
from django_cas_ng.views import LoginView
|
||||
|
||||
__all__ = ['LoginView']
|
||||
|
||||
from authentication.views.utils import redirect_to_guard_view
|
||||
|
||||
|
||||
class CASLoginView(LoginView):
|
||||
def get(self, request):
|
||||
|
@ -13,3 +16,8 @@ class CASLoginView(LoginView):
|
|||
return HttpResponseRedirect('/')
|
||||
|
||||
|
||||
class CASCallbackClientView(View):
|
||||
http_method_names = ['get', ]
|
||||
|
||||
def get(self, request):
|
||||
return redirect_to_guard_view(query_string='next=client')
|
||||
|
|
|
@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
|||
|
||||
from authentication.signals import user_auth_failed, user_auth_success
|
||||
from common.utils import get_logger
|
||||
from .base import JMSModelBackend
|
||||
from .base import JMSBaseAuthBackend
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
@ -20,9 +20,10 @@ if settings.AUTH_CUSTOM:
|
|||
logger.warning('Import custom auth method failed: {}, Maybe not enabled'.format(e))
|
||||
|
||||
|
||||
class CustomAuthBackend(JMSModelBackend):
|
||||
class CustomAuthBackend(JMSBaseAuthBackend):
|
||||
|
||||
def is_enabled(self):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_CUSTOM and callable(custom_authenticate_method)
|
||||
|
||||
@staticmethod
|
||||
|
@ -35,10 +36,10 @@ class CustomAuthBackend(JMSModelBackend):
|
|||
)
|
||||
return user, created
|
||||
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
def authenticate(self, request, username=None, password=None):
|
||||
try:
|
||||
userinfo: dict = custom_authenticate_method(
|
||||
username=username, password=password, **kwargs
|
||||
username=username, password=password
|
||||
)
|
||||
user, created = self.get_or_create_user_from_userinfo(userinfo)
|
||||
except Exception as e:
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import base64
|
||||
import requests
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -17,7 +18,7 @@ from common.exceptions import JMSException
|
|||
from .signals import (
|
||||
oauth2_create_or_update_user
|
||||
)
|
||||
from ..base import JMSModelBackend
|
||||
from ..base import JMSBaseAuthBackend
|
||||
|
||||
|
||||
__all__ = ['OAuth2Backend']
|
||||
|
@ -25,7 +26,7 @@ __all__ = ['OAuth2Backend']
|
|||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OAuth2Backend(JMSModelBackend):
|
||||
class OAuth2Backend(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_OAUTH2
|
||||
|
@ -67,15 +68,7 @@ class OAuth2Backend(JMSModelBackend):
|
|||
response_data = response_data['data']
|
||||
return response_data
|
||||
|
||||
@staticmethod
|
||||
def get_query_dict(response_data, query_dict):
|
||||
query_dict.update({
|
||||
'uid': response_data.get('uid', ''),
|
||||
'access_token': response_data.get('access_token', '')
|
||||
})
|
||||
return query_dict
|
||||
|
||||
def authenticate(self, request, code=None, **kwargs):
|
||||
def authenticate(self, request, code=None):
|
||||
log_prompt = "Process authenticate [OAuth2Backend]: {}"
|
||||
logger.debug(log_prompt.format('Start'))
|
||||
if code is None:
|
||||
|
@ -83,29 +76,31 @@ class OAuth2Backend(JMSModelBackend):
|
|||
return None
|
||||
|
||||
query_dict = {
|
||||
'client_id': settings.AUTH_OAUTH2_CLIENT_ID,
|
||||
'client_secret': settings.AUTH_OAUTH2_CLIENT_SECRET,
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code', 'code': code,
|
||||
'redirect_uri': build_absolute_uri(
|
||||
request, path=reverse(settings.AUTH_OAUTH2_AUTH_LOGIN_CALLBACK_URL_NAME)
|
||||
)
|
||||
}
|
||||
if '?' in settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT:
|
||||
separator = '&'
|
||||
else:
|
||||
separator = '?'
|
||||
separator = '&' if '?' in settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT else '?'
|
||||
access_token_url = '{url}{separator}{query}'.format(
|
||||
url=settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT, separator=separator, query=urlencode(query_dict)
|
||||
url=settings.AUTH_OAUTH2_ACCESS_TOKEN_ENDPOINT,
|
||||
separator=separator, query=urlencode(query_dict)
|
||||
)
|
||||
# token_method -> get, post(post_data), post_json
|
||||
token_method = settings.AUTH_OAUTH2_ACCESS_TOKEN_METHOD.lower()
|
||||
logger.debug(log_prompt.format('Call the access token endpoint[method: %s]' % token_method))
|
||||
encoded_credentials = base64.b64encode(
|
||||
f"{settings.AUTH_OAUTH2_CLIENT_ID}:{settings.AUTH_OAUTH2_CLIENT_SECRET}".encode()
|
||||
).decode()
|
||||
headers = {
|
||||
'Accept': 'application/json'
|
||||
'Accept': 'application/json', 'Authorization': f'Basic {encoded_credentials}'
|
||||
}
|
||||
if token_method.startswith('post'):
|
||||
body_key = 'json' if token_method.endswith('json') else 'data'
|
||||
query_dict.update({
|
||||
'client_id': settings.AUTH_OAUTH2_CLIENT_ID,
|
||||
'client_secret': settings.AUTH_OAUTH2_CLIENT_SECRET,
|
||||
})
|
||||
access_token_response = requests.post(
|
||||
access_token_url, headers=headers, **{body_key: query_dict}
|
||||
)
|
||||
|
@ -121,22 +116,12 @@ class OAuth2Backend(JMSModelBackend):
|
|||
logger.error(log_prompt.format(error))
|
||||
return None
|
||||
|
||||
query_dict = self.get_query_dict(response_data, query_dict)
|
||||
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': 'Bearer {}'.format(response_data.get('access_token', ''))
|
||||
}
|
||||
|
||||
logger.debug(log_prompt.format('Get userinfo endpoint'))
|
||||
if '?' in settings.AUTH_OAUTH2_PROVIDER_USERINFO_ENDPOINT:
|
||||
separator = '&'
|
||||
else:
|
||||
separator = '?'
|
||||
userinfo_url = '{url}{separator}{query}'.format(
|
||||
url=settings.AUTH_OAUTH2_PROVIDER_USERINFO_ENDPOINT, separator=separator,
|
||||
query=urlencode(query_dict)
|
||||
)
|
||||
userinfo_url = settings.AUTH_OAUTH2_PROVIDER_USERINFO_ENDPOINT
|
||||
userinfo_response = requests.get(userinfo_url, headers=headers)
|
||||
try:
|
||||
userinfo_response.raise_for_status()
|
||||
|
|
|
@ -4,9 +4,9 @@ from django.urls import path
|
|||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', views.OAuth2AuthRequestView.as_view(), name='login'),
|
||||
path('callback/', views.OAuth2AuthCallbackView.as_view(), name='login-callback'),
|
||||
path('callback/client/', views.OAuth2AuthCallbackClientView.as_view(), name='login-callback-client'),
|
||||
path('logout/', views.OAuth2EndSessionView.as_view(), name='logout')
|
||||
]
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
from django.views import View
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.views import View
|
||||
|
||||
from authentication.mixins import authenticate
|
||||
from authentication.utils import build_absolute_uri
|
||||
from authentication.views.mixins import FlashMessageMixin
|
||||
from authentication.mixins import authenticate
|
||||
from authentication.views.utils import redirect_to_guard_view
|
||||
from common.utils import get_logger
|
||||
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
|
@ -67,6 +67,13 @@ class OAuth2AuthCallbackView(View, FlashMessageMixin):
|
|||
return HttpResponseRedirect(redirect_url)
|
||||
|
||||
|
||||
class OAuth2AuthCallbackClientView(View):
|
||||
http_method_names = ['get', ]
|
||||
|
||||
def get(self, request):
|
||||
return redirect_to_guard_view(query_string='next=client')
|
||||
|
||||
|
||||
class OAuth2EndSessionView(View):
|
||||
http_method_names = ['get', 'post', ]
|
||||
|
||||
|
|
|
@ -13,10 +13,8 @@ import requests
|
|||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
from rest_framework.exceptions import ParseError
|
||||
|
||||
from authentication.signals import user_auth_success, user_auth_failed
|
||||
from authentication.utils import build_absolute_uri_for_oidc
|
||||
|
@ -88,7 +86,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
|||
"""
|
||||
|
||||
@ssl_verification
|
||||
def authenticate(self, request, nonce=None, code_verifier=None, **kwargs):
|
||||
def authenticate(self, request, nonce=None, code_verifier=None):
|
||||
""" Authenticates users in case of the OpenID Connect Authorization code flow. """
|
||||
log_prompt = "Process authenticate [OIDCAuthCodeBackend]: {}"
|
||||
logger.debug(log_prompt.format('start'))
|
||||
|
@ -107,7 +105,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
|||
# parameters because we won't be able to get a valid token for the user in that case.
|
||||
if (state is None and settings.AUTH_OPENID_USE_STATE) or code is None:
|
||||
logger.debug(log_prompt.format('Authorization code or state value is missing'))
|
||||
raise SuspiciousOperation('Authorization code or state value is missing')
|
||||
return
|
||||
|
||||
# Prepares the token payload that will be used to request an authentication token to the
|
||||
# token endpoint of the OIDC provider.
|
||||
|
@ -165,7 +163,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
|||
error = "Json token response error, token response " \
|
||||
"content is: {}, error is: {}".format(token_response.content, str(e))
|
||||
logger.debug(log_prompt.format(error))
|
||||
raise ParseError(error)
|
||||
return
|
||||
|
||||
# Validates the token.
|
||||
logger.debug(log_prompt.format('Validate ID Token'))
|
||||
|
@ -206,7 +204,7 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
|||
error = "Json claims response error, claims response " \
|
||||
"content is: {}, error is: {}".format(claims_response.content, str(e))
|
||||
logger.debug(log_prompt.format(error))
|
||||
raise ParseError(error)
|
||||
return
|
||||
|
||||
logger.debug(log_prompt.format('Get or create user from claims'))
|
||||
user, created = self.get_or_create_user_from_claims(request, claims)
|
||||
|
@ -235,15 +233,15 @@ class OIDCAuthCodeBackend(OIDCBaseBackend):
|
|||
class OIDCAuthPasswordBackend(OIDCBaseBackend):
|
||||
|
||||
@ssl_verification
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
def authenticate(self, request, username=None, password=None):
|
||||
try:
|
||||
return self._authenticate(request, username, password, **kwargs)
|
||||
return self._authenticate(request, username, password)
|
||||
except Exception as e:
|
||||
error = f'Authenticate exception: {e}'
|
||||
logger.error(error, exc_info=True)
|
||||
return
|
||||
|
||||
def _authenticate(self, request, username=None, password=None, **kwargs):
|
||||
def _authenticate(self, request, username=None, password=None):
|
||||
"""
|
||||
https://oauth.net/2/
|
||||
https://aaronparecki.com/oauth-2-simplified/#password
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
import warnings
|
||||
import contextlib
|
||||
import requests
|
||||
import inspect
|
||||
|
||||
from functools import wraps
|
||||
from django.conf import settings
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
|
||||
|
@ -52,6 +54,7 @@ def no_ssl_verification():
|
|||
|
||||
|
||||
def ssl_verification(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not settings.AUTH_OPENID_IGNORE_SSL_VERIFICATION:
|
||||
return func(*args, **kwargs)
|
||||
|
|
|
@ -12,9 +12,9 @@ from django.urls import path
|
|||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', views.OIDCAuthRequestView.as_view(), name='login'),
|
||||
path('callback/', views.OIDCAuthCallbackView.as_view(), name='login-callback'),
|
||||
path('callback/client/', views.OIDCAuthCallbackClientView.as_view(), name='login-callback-client'),
|
||||
path('logout/', views.OIDCEndSessionView.as_view(), name='logout'),
|
||||
]
|
||||
|
|
|
@ -22,13 +22,14 @@ from django.http import HttpResponseRedirect, QueryDict
|
|||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.http import urlencode
|
||||
from django.views.generic import View
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
|
||||
from authentication.utils import build_absolute_uri_for_oidc
|
||||
from authentication.views.mixins import FlashMessageMixin
|
||||
from common.utils import safe_next_url
|
||||
from .utils import get_logger
|
||||
from ...views.utils import redirect_to_guard_view
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
@ -208,6 +209,13 @@ class OIDCAuthCallbackView(View, FlashMessageMixin):
|
|||
return HttpResponseRedirect(settings.AUTH_OPENID_AUTHENTICATION_FAILURE_REDIRECT_URI)
|
||||
|
||||
|
||||
class OIDCAuthCallbackClientView(View):
|
||||
http_method_names = ['get', ]
|
||||
|
||||
def get(self, request):
|
||||
return redirect_to_guard_view(query_string='next=client')
|
||||
|
||||
|
||||
class OIDCEndSessionView(View):
|
||||
""" Allows to end the session of any user authenticated using OpenID Connect.
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ class Passkey(JMSBaseModel):
|
|||
added_on = models.DateTimeField(auto_now_add=True, verbose_name=_("Added on"))
|
||||
date_last_used = models.DateTimeField(null=True, default=None, verbose_name=_("Date last used"))
|
||||
credential_id = models.CharField(max_length=255, unique=True, null=False, verbose_name=_("Credential ID"))
|
||||
token = models.CharField(max_length=255, null=False, verbose_name=_("Token"))
|
||||
token = models.CharField(max_length=1024, null=False, verbose_name=_("Token"))
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
|
|
@ -51,10 +51,10 @@ class RadiusBaseBackend(CreateUserMixin, JMSBaseAuthBackend):
|
|||
|
||||
|
||||
class RadiusBackend(RadiusBaseBackend, RADIUSBackend):
|
||||
def authenticate(self, request, username='', password='', **kwargs):
|
||||
def authenticate(self, request, username='', password=''):
|
||||
return super().authenticate(request, username=username, password=password)
|
||||
|
||||
|
||||
class RadiusRealmBackend(RadiusBaseBackend, RADIUSRealmBackend):
|
||||
def authenticate(self, request, username='', password='', realm=None, **kwargs):
|
||||
def authenticate(self, request, username='', password='', realm=None):
|
||||
return super().authenticate(request, username=username, password=password, realm=realm)
|
||||
|
|
|
@ -10,14 +10,14 @@ from .signals import (
|
|||
saml2_create_or_update_user
|
||||
)
|
||||
from authentication.signals import user_auth_failed, user_auth_success
|
||||
from ..base import JMSModelBackend
|
||||
from ..base import JMSBaseAuthBackend
|
||||
|
||||
__all__ = ['SAML2Backend']
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SAML2Backend(JMSModelBackend):
|
||||
class SAML2Backend(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_SAML2
|
||||
|
@ -42,7 +42,7 @@ class SAML2Backend(JMSModelBackend):
|
|||
)
|
||||
return user, created
|
||||
|
||||
def authenticate(self, request, saml_user_data=None, **kwargs):
|
||||
def authenticate(self, request, saml_user_data=None):
|
||||
log_prompt = "Process authenticate [SAML2Backend]: {}"
|
||||
logger.debug(log_prompt.format('Start'))
|
||||
if saml_user_data is None:
|
||||
|
|
|
@ -4,10 +4,10 @@ from django.urls import path
|
|||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', views.Saml2AuthRequestView.as_view(), name='saml2-login'),
|
||||
path('logout/', views.Saml2EndSessionView.as_view(), name='saml2-logout'),
|
||||
path('callback/', views.Saml2AuthCallbackView.as_view(), name='saml2-callback'),
|
||||
path('callback/client/', views.Saml2AuthCallbackClientView.as_view(), name='saml2-callback-client'),
|
||||
path('metadata/', views.Saml2AuthMetadataView.as_view(), name='saml2-metadata'),
|
||||
]
|
||||
|
|
|
@ -19,6 +19,7 @@ from onelogin.saml2.idp_metadata_parser import (
|
|||
from authentication.views.mixins import FlashMessageMixin
|
||||
from common.utils import get_logger
|
||||
from .settings import JmsSaml2Settings
|
||||
from ...views.utils import redirect_to_guard_view
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
@ -298,6 +299,13 @@ class Saml2AuthCallbackView(View, PrepareRequestMixin, FlashMessageMixin):
|
|||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
|
||||
class Saml2AuthCallbackClientView(View):
|
||||
http_method_names = ['get', ]
|
||||
|
||||
def get(self, request):
|
||||
return redirect_to_guard_view(query_string='next=client')
|
||||
|
||||
|
||||
class Saml2AuthMetadataView(View, PrepareRequestMixin):
|
||||
|
||||
def get(self, request):
|
||||
|
|
|
@ -1,57 +1,41 @@
|
|||
from django.conf import settings
|
||||
|
||||
from .base import JMSModelBackend
|
||||
from .base import JMSBaseAuthBackend
|
||||
|
||||
|
||||
class SSOAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
class SSOAuthentication(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_SSO
|
||||
|
||||
def authenticate(self, request, sso_token=None, **kwargs):
|
||||
def authenticate(self):
|
||||
pass
|
||||
|
||||
|
||||
class WeComAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
class WeComAuthentication(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_WECOM
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
def authenticate(self):
|
||||
pass
|
||||
|
||||
|
||||
class DingTalkAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
class DingTalkAuthentication(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_DINGTALK
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
def authenticate(self):
|
||||
pass
|
||||
|
||||
|
||||
class FeiShuAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
class FeiShuAuthentication(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_FEISHU
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
def authenticate(self):
|
||||
pass
|
||||
|
||||
|
||||
|
@ -61,23 +45,15 @@ class LarkAuthentication(FeiShuAuthentication):
|
|||
return settings.AUTH_LARK
|
||||
|
||||
|
||||
class SlackAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
class SlackAuthentication(JMSBaseAuthBackend):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_SLACK
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
def authenticate(self):
|
||||
pass
|
||||
|
||||
|
||||
class AuthorizationTokenAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
class AuthorizationTokenAuthentication(JMSBaseAuthBackend):
|
||||
def authenticate(self):
|
||||
pass
|
||||
|
|
|
@ -3,13 +3,17 @@ from django.conf import settings
|
|||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
from authentication.models import TempToken
|
||||
from .base import JMSModelBackend
|
||||
from .base import JMSBaseAuthBackend
|
||||
|
||||
|
||||
class TempTokenAuthBackend(JMSModelBackend):
|
||||
class TempTokenAuthBackend(JMSBaseAuthBackend):
|
||||
model = TempToken
|
||||
|
||||
def authenticate(self, request, username='', password='', *args, **kwargs):
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_TEMP_TOKEN
|
||||
|
||||
def authenticate(self, request, username='', password=''):
|
||||
token = self.model.objects.filter(username=username, secret=password).first()
|
||||
if not token:
|
||||
return None
|
||||
|
@ -21,6 +25,3 @@ class TempTokenAuthBackend(JMSModelBackend):
|
|||
token.save()
|
||||
return token.user
|
||||
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_TEMP_TOKEN
|
||||
|
|
|
@ -40,6 +40,12 @@ class MFAType(TextChoices):
|
|||
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"
|
||||
FACE_CONTEXT_CACHE_KEY_PREFIX = "FACE_CONTEXT"
|
||||
FACE_CONTEXT_CACHE_TTL = 60
|
||||
FACE_SESSION_KEY = "face_token"
|
||||
|
||||
|
||||
class FaceMonitorActionChoices(TextChoices):
|
||||
Verify = 'verify', 'verify'
|
||||
Pause = 'pause', 'pause'
|
||||
Resume = 'resume', 'resume'
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
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 authentication.mixins import AuthFaceMixin
|
||||
from common.const import LicenseEditionChoices
|
||||
from settings.api import settings
|
||||
|
||||
|
||||
class MFAFace(BaseMFA, MFAFaceMixin):
|
||||
class MFAFace(BaseMFA, AuthFaceMixin):
|
||||
name = "face"
|
||||
display_name = _('Face Recognition')
|
||||
placeholder = 'Face Recognition'
|
||||
|
@ -39,10 +36,10 @@ class MFAFace(BaseMFA, MFAFaceMixin):
|
|||
and settings.FACE_RECOGNITION_ENABLED
|
||||
|
||||
def get_enable_url(self) -> str:
|
||||
return reverse('authentication:user-face-enable')
|
||||
return '/ui/#/profile/index'
|
||||
|
||||
def get_disable_url(self) -> str:
|
||||
return reverse('authentication:user-face-disable')
|
||||
return '/ui/#/profile/index'
|
||||
|
||||
def disable(self):
|
||||
assert self.is_authenticated()
|
||||
|
@ -54,4 +51,8 @@ class MFAFace(BaseMFA, MFAFaceMixin):
|
|||
|
||||
@staticmethod
|
||||
def help_text_of_enable():
|
||||
return _("Frontal Face Recognition")
|
||||
return _("Bind face to enable")
|
||||
|
||||
@staticmethod
|
||||
def help_text_of_disable():
|
||||
return _("Unbind face to disable")
|
||||
|
|
|
@ -35,7 +35,7 @@ class MFAMiddleware:
|
|||
|
||||
# 这个是 mfa 登录页需要的请求, 也得放出来, 用户其实已经在 CAS/OIDC 中完成登录了
|
||||
white_urls = [
|
||||
'login/mfa', 'mfa/select', 'mfa/face','jsi18n/', '/static/',
|
||||
'login/mfa', 'mfa/select', 'face/context','jsi18n/', '/static/',
|
||||
'/profile/otp', '/logout/',
|
||||
]
|
||||
for url in white_urls:
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.1.13 on 2024-12-12 06:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('authentication', '0003_sshkey'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='passkey',
|
||||
name='token',
|
||||
field=models.CharField(max_length=1024, verbose_name='Token'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.1.13 on 2024-12-11 02:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('authentication', '0004_alter_passkey_token'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='connectiontoken',
|
||||
name='face_monitor_token',
|
||||
field=models.CharField(blank=True, max_length=128, null=True, verbose_name='Face monitor token'),
|
||||
),
|
||||
]
|
|
@ -2,6 +2,7 @@
|
|||
#
|
||||
import inspect
|
||||
import time
|
||||
import uuid
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
|
||||
|
@ -23,6 +24,7 @@ from common.utils import get_request_ip_or_data, get_request_ip, get_logger, bul
|
|||
from users.models import User
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil
|
||||
from . import errors
|
||||
from .const import FACE_CONTEXT_CACHE_TTL, FACE_SESSION_KEY, FACE_CONTEXT_CACHE_KEY_PREFIX
|
||||
from .signals import post_auth_success, post_auth_failed
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
@ -199,53 +201,6 @@ 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
|
||||
|
@ -475,7 +430,68 @@ class AuthACLMixin:
|
|||
return ticket
|
||||
|
||||
|
||||
class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPostCheckMixin):
|
||||
class AuthFaceMixin:
|
||||
request: Request
|
||||
|
||||
@staticmethod
|
||||
def _get_face_cache_key(token):
|
||||
return f"{FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}"
|
||||
|
||||
@staticmethod
|
||||
def _is_context_finished(context):
|
||||
return context.get('is_finished', False)
|
||||
|
||||
@staticmethod
|
||||
def _is_context_success(context):
|
||||
return context.get('success', False)
|
||||
|
||||
def create_face_verify_context(self, data=None):
|
||||
token = uuid.uuid4().hex
|
||||
context_data = {
|
||||
"action": "mfa",
|
||||
"token": token,
|
||||
"user_id": self.request.user.id,
|
||||
"is_finished": False
|
||||
}
|
||||
if data:
|
||||
context_data.update(data)
|
||||
|
||||
cache_key = self._get_face_cache_key(token)
|
||||
cache.set(cache_key, context_data, FACE_CONTEXT_CACHE_TTL)
|
||||
self.request.session[FACE_SESSION_KEY] = token
|
||||
return token
|
||||
|
||||
def get_face_token_from_session(self):
|
||||
token = self.request.session.get(FACE_SESSION_KEY)
|
||||
if not token:
|
||||
raise ValueError("Face recognition token is missing from the session.")
|
||||
return token
|
||||
|
||||
def get_face_verify_context(self):
|
||||
token = self.get_face_token_from_session()
|
||||
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
|
||||
|
||||
def get_face_code(self):
|
||||
context = self.get_face_verify_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 AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, AuthFaceMixin, MFAMixin, AuthPostCheckMixin, ):
|
||||
request = None
|
||||
partial_credential_error = None
|
||||
|
||||
|
@ -577,7 +593,8 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPost
|
|||
keys = [
|
||||
'auth_password', 'user_id', 'auth_confirm_required',
|
||||
'auth_notice_required', 'auth_ticket_id', 'auth_acl_id',
|
||||
'user_session_id', 'user_log_id', 'can_send_notifications'
|
||||
'user_session_id', 'user_log_id', 'can_send_notifications',
|
||||
'next',
|
||||
]
|
||||
for k in keys:
|
||||
self.request.session.pop(k, '')
|
||||
|
|
|
@ -50,6 +50,7 @@ class ConnectionToken(JMSOrgBaseModel):
|
|||
on_delete=models.SET_NULL, null=True, blank=True,
|
||||
verbose_name=_('From ticket')
|
||||
)
|
||||
face_monitor_token = models.CharField(max_length=128, null=True, blank=True, verbose_name=_("Face monitor token"))
|
||||
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -4,3 +4,4 @@ from .connection_token import *
|
|||
from .password_mfa import *
|
||||
from .ssh_key import *
|
||||
from .token import *
|
||||
from .face import *
|
||||
|
|
|
@ -148,9 +148,10 @@ class ConnectionTokenSecretSerializer(OrgResourceModelSerializerMixin):
|
|||
'platform', 'command_filter_acls', 'protocol',
|
||||
'domain', 'gateway', 'actions', 'expire_at',
|
||||
'from_ticket', 'expire_now', 'connect_method',
|
||||
'connect_options',
|
||||
'connect_options', 'face_monitor_token'
|
||||
]
|
||||
extra_kwargs = {
|
||||
'face_monitor_token': {'read_only': True},
|
||||
'value': {'read_only': True},
|
||||
}
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ class ConnectionTokenSerializer(CommonModelSerializer):
|
|||
'connect_method', 'connect_options', 'protocol', 'actions',
|
||||
'is_active', 'is_reusable', 'from_ticket', 'from_ticket_info',
|
||||
'date_expired', 'date_created', 'date_updated', 'created_by',
|
||||
'updated_by', 'org_id', 'org_name',
|
||||
'updated_by', 'org_id', 'org_name','face_monitor_token',
|
||||
]
|
||||
read_only_fields = [
|
||||
# 普通 Token 不支持指定 user
|
||||
|
@ -37,6 +37,7 @@ class ConnectionTokenSerializer(CommonModelSerializer):
|
|||
]
|
||||
fields = fields_small + read_only_fields
|
||||
extra_kwargs = {
|
||||
'face_monitor_token': {'read_only': True},
|
||||
'from_ticket': {'read_only': True},
|
||||
'value': {'read_only': True},
|
||||
'is_expired': {'read_only': True, 'label': _('Is expired')},
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
from django.core.validators import RegexValidator
|
||||
from rest_framework import serializers
|
||||
|
||||
__all__ = [
|
||||
'FaceCallbackSerializer', 'FaceMonitorCallbackSerializer'
|
||||
]
|
||||
|
||||
from authentication.const import FaceMonitorActionChoices
|
||||
|
||||
|
||||
class FaceCallbackSerializer(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
|
||||
|
||||
|
||||
class FaceMonitorContextSerializer(serializers.Serializer):
|
||||
session_id = serializers.CharField(required=True, allow_null=False, allow_blank=False)
|
||||
face_monitor_token = serializers.CharField(required=True, allow_blank=False, allow_null=False)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
pass
|
||||
|
||||
def create(self, validated_data):
|
||||
pass
|
||||
|
||||
|
||||
class FaceMonitorCallbackSerializer(serializers.Serializer):
|
||||
token = serializers.CharField(required=True, allow_blank=False)
|
||||
is_finished = serializers.BooleanField(required=True)
|
||||
success = serializers.BooleanField(required=True)
|
||||
error_message = serializers.CharField(required=True, allow_blank=True)
|
||||
action = serializers.ChoiceField(required=True, choices=FaceMonitorActionChoices.choices)
|
||||
face_codes = serializers.ListField(
|
||||
required=False, allow_null=True, allow_empty=True,
|
||||
child=serializers.CharField(),
|
||||
)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
pass
|
||||
|
||||
def create(self, validated_data):
|
||||
pass
|
|
@ -8,7 +8,6 @@ from common.serializers.fields import EncryptedField
|
|||
__all__ = [
|
||||
'MFAChallengeSerializer', 'MFASelectTypeSerializer',
|
||||
'PasswordVerifySerializer', 'ResetPasswordCodeSerializer',
|
||||
'MFAFaceCallbackSerializer'
|
||||
]
|
||||
|
||||
|
||||
|
@ -52,16 +51,3 @@ 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
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
const apiUrl = "{% url 'api-auth:mfa-face-context' %}";
|
||||
const apiUrl = "{% url 'api-auth:face-context' %}";
|
||||
const faceCaptureUrl = "/facelive/capture";
|
||||
let token;
|
||||
|
||||
|
@ -85,7 +85,7 @@
|
|||
}
|
||||
|
||||
$('#retry_button').on('click', function () {
|
||||
window.location.href = "{% url 'authentication:login-face-capture' %}";
|
||||
window.location.href = "{{ request.get_full_path }}";
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -26,6 +26,12 @@ urlpatterns = [
|
|||
path('lark/event/subscription/callback/', api.LarkEventSubscriptionCallback.as_view(),
|
||||
name='lark-event-subscription-callback'),
|
||||
|
||||
path('face/callback/', api.FaceCallbackApi.as_view(), name='face-callback'),
|
||||
path('face/context/', api.FaceContextApi.as_view(), name='face-context'),
|
||||
|
||||
path('face-monitor/callback/', api.FaceMonitorCallbackApi.as_view(), name='face-monitor-callback'),
|
||||
path('face-monitor/context/', api.FaceMonitorContextApi.as_view(), name='face-monitor-context'),
|
||||
|
||||
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
|
||||
path('confirm-oauth/', api.ConfirmBindORUNBindOAuth.as_view(), name='confirm-oauth'),
|
||||
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
||||
|
@ -33,8 +39,6 @@ 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'),
|
||||
|
|
|
@ -110,6 +110,9 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, IMClientMixin, View):
|
|||
msg = e.msg
|
||||
response = self.get_failed_response(login_url, title=msg, msg=msg)
|
||||
return response
|
||||
|
||||
if 'next=client' in redirect_url:
|
||||
self.request.META['QUERY_STRING'] += '&next=client'
|
||||
return self.redirect_to_guard_view()
|
||||
|
||||
|
||||
|
|
|
@ -175,6 +175,8 @@ class DingTalkQRLoginView(DingTalkQRMixin, METAMixin, View):
|
|||
|
||||
def get(self, request: HttpRequest):
|
||||
redirect_url = request.GET.get('redirect_url') or reverse('index')
|
||||
query_string = request.GET.urlencode()
|
||||
redirect_url = f'{redirect_url}?{query_string}'
|
||||
next_url = self.get_next_url_from_meta() or reverse('index')
|
||||
next_url = safe_next_url(next_url, request=request)
|
||||
|
||||
|
|
|
@ -110,6 +110,8 @@ class FeiShuQRLoginView(FeiShuQRMixin, View):
|
|||
|
||||
def get(self, request: HttpRequest):
|
||||
redirect_url = request.GET.get('redirect_url') or reverse('index')
|
||||
query_string = request.GET.urlencode()
|
||||
redirect_url = f'{redirect_url}?{query_string}'
|
||||
redirect_uri = reverse(f'authentication:{self.category}-qr-login-callback', external=True)
|
||||
redirect_uri += '?' + urlencode({
|
||||
'redirect_url': redirect_url,
|
||||
|
|
|
@ -45,69 +45,70 @@ class UserLoginContextMixin:
|
|||
error_origin: str
|
||||
|
||||
def get_support_auth_methods(self):
|
||||
query_string = self.request.GET.urlencode()
|
||||
auth_methods = [
|
||||
{
|
||||
'name': 'OpenID',
|
||||
'enabled': settings.AUTH_OPENID,
|
||||
'url': reverse('authentication:openid:login'),
|
||||
'url': f"{reverse('authentication:openid:login')}?{query_string}",
|
||||
'logo': static('img/login_oidc_logo.png'),
|
||||
'auto_redirect': True # 是否支持自动重定向
|
||||
},
|
||||
{
|
||||
'name': 'CAS',
|
||||
'enabled': settings.AUTH_CAS,
|
||||
'url': reverse('authentication:cas:cas-login'),
|
||||
'url': f"{reverse('authentication:cas:cas-login')}?{query_string}",
|
||||
'logo': static('img/login_cas_logo.png'),
|
||||
'auto_redirect': True
|
||||
},
|
||||
{
|
||||
'name': 'SAML2',
|
||||
'enabled': settings.AUTH_SAML2,
|
||||
'url': reverse('authentication:saml2:saml2-login'),
|
||||
'url': f"{reverse('authentication:saml2:saml2-login')}?{query_string}",
|
||||
'logo': static('img/login_saml2_logo.png'),
|
||||
'auto_redirect': True
|
||||
},
|
||||
{
|
||||
'name': settings.AUTH_OAUTH2_PROVIDER,
|
||||
'enabled': settings.AUTH_OAUTH2,
|
||||
'url': reverse('authentication:oauth2:login'),
|
||||
'url': f"{reverse('authentication:oauth2:login')}?{query_string}",
|
||||
'logo': static_or_direct(settings.AUTH_OAUTH2_LOGO_PATH),
|
||||
'auto_redirect': True
|
||||
},
|
||||
{
|
||||
'name': _('WeCom'),
|
||||
'enabled': settings.AUTH_WECOM,
|
||||
'url': reverse('authentication:wecom-qr-login'),
|
||||
'url': f"{reverse('authentication:wecom-qr-login')}?{query_string}",
|
||||
'logo': static('img/login_wecom_logo.png'),
|
||||
},
|
||||
{
|
||||
'name': _('DingTalk'),
|
||||
'enabled': settings.AUTH_DINGTALK,
|
||||
'url': reverse('authentication:dingtalk-qr-login'),
|
||||
'url': f"{reverse('authentication:dingtalk-qr-login')}?{query_string}",
|
||||
'logo': static('img/login_dingtalk_logo.png')
|
||||
},
|
||||
{
|
||||
'name': _('FeiShu'),
|
||||
'enabled': settings.AUTH_FEISHU,
|
||||
'url': reverse('authentication:feishu-qr-login'),
|
||||
'url': f"{reverse('authentication:feishu-qr-login')}?{query_string}",
|
||||
'logo': static('img/login_feishu_logo.png')
|
||||
},
|
||||
{
|
||||
'name': 'Lark',
|
||||
'enabled': settings.AUTH_LARK,
|
||||
'url': reverse('authentication:lark-qr-login'),
|
||||
'url': f"{reverse('authentication:lark-qr-login')}?{query_string}",
|
||||
'logo': static('img/login_lark_logo.png')
|
||||
},
|
||||
{
|
||||
'name': _('Slack'),
|
||||
'enabled': settings.AUTH_SLACK,
|
||||
'url': reverse('authentication:slack-qr-login'),
|
||||
'url': f"{reverse('authentication:slack-qr-login')}?{query_string}",
|
||||
'logo': static('img/login_slack_logo.png')
|
||||
},
|
||||
{
|
||||
'name': _("Passkey"),
|
||||
'enabled': settings.AUTH_PASSKEY,
|
||||
'url': reverse('api-auth:passkey-login'),
|
||||
'url': f"{reverse('api-auth:passkey-login')}?{query_string}",
|
||||
'logo': static('img/login_passkey.png')
|
||||
}
|
||||
]
|
||||
|
@ -220,12 +221,16 @@ class UserLoginView(mixins.AuthMixin, UserLoginContextMixin, FormView):
|
|||
'redirect_url': redirect_url,
|
||||
'interval': 3,
|
||||
'has_cancel': True,
|
||||
'cancel_url': reverse('authentication:login') + '?admin=1'
|
||||
'cancel_url': reverse('authentication:login') + f'?admin=1&{query_string}'
|
||||
}
|
||||
redirect_url = FlashMessageUtil.gen_message_url(message_data)
|
||||
return redirect_url
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
next_page = request.GET.get(self.redirect_field_name)
|
||||
if next_page:
|
||||
request.session[self.redirect_field_name] = next_page
|
||||
|
||||
if request.user.is_staff:
|
||||
first_login_url = redirect_user_first_login_or_index(
|
||||
request, self.redirect_field_name
|
||||
|
@ -306,6 +311,7 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView):
|
|||
login_url = reverse_lazy('authentication:login')
|
||||
login_mfa_url = reverse_lazy('authentication:login-mfa')
|
||||
login_confirm_url = reverse_lazy('authentication:login-wait-confirm')
|
||||
query_string = True
|
||||
|
||||
def format_redirect_url(self, url):
|
||||
args = self.request.META.get('QUERY_STRING', '')
|
||||
|
|
|
@ -2,13 +2,14 @@
|
|||
#
|
||||
|
||||
from __future__ import unicode_literals
|
||||
from django.views.generic.edit import FormView
|
||||
|
||||
from django.shortcuts import redirect, reverse
|
||||
from django.views.generic.edit import FormView
|
||||
|
||||
from common.utils import get_logger
|
||||
from users.views import UserFaceCaptureView
|
||||
from .. import forms, errors, mixins
|
||||
from .utils import redirect_to_guard_view
|
||||
from .. import forms, errors, mixins
|
||||
from ..const import MFAType
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
@ -48,7 +49,8 @@ class UserLoginMFAView(mixins.AuthMixin, FormView):
|
|||
self._do_check_user_mfa(code, mfa_type)
|
||||
user, ip = self.get_user_from_session(), self.get_request_ip()
|
||||
MFABlockUtils(user.username, ip).clean_failed_count()
|
||||
return redirect_to_guard_view('mfa_ok')
|
||||
query_string = self.request.GET.urlencode()
|
||||
return redirect_to_guard_view('mfa_ok', query_string)
|
||||
except (errors.MFAFailedError, errors.BlockMFAError) as e:
|
||||
form.add_error('code', e.msg)
|
||||
return super().form_invalid(form)
|
||||
|
|
|
@ -98,6 +98,8 @@ class SlackQRLoginView(SlackMixin, View):
|
|||
|
||||
def get(self, request: Request):
|
||||
redirect_url = request.GET.get('redirect_url') or reverse('index')
|
||||
query_string = request.GET.urlencode()
|
||||
redirect_url = f'{redirect_url}?{query_string}'
|
||||
redirect_uri = reverse('authentication:slack-qr-login-callback', external=True)
|
||||
redirect_uri += '?' + urlencode({
|
||||
'redirect_url': redirect_url,
|
||||
|
|
|
@ -1,8 +1,18 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from urllib.parse import urlencode, parse_qsl
|
||||
|
||||
from django.shortcuts import reverse, redirect
|
||||
|
||||
|
||||
def redirect_to_guard_view(comment=''):
|
||||
continue_url = reverse('authentication:login-guard') + '?_=' + comment
|
||||
def redirect_to_guard_view(comment='', query_string=None):
|
||||
params = {'_': comment}
|
||||
base_url = reverse('authentication:login-guard')
|
||||
|
||||
if query_string:
|
||||
params.update(dict(parse_qsl(query_string)))
|
||||
|
||||
query_string = urlencode(params)
|
||||
|
||||
continue_url = f"{base_url}?{query_string}"
|
||||
return redirect(continue_url)
|
||||
|
|
|
@ -22,7 +22,6 @@ from users.views import UserVerifyPasswordView
|
|||
from .base import BaseLoginCallbackView, BaseBindCallbackView
|
||||
from .mixins import METAMixin, FlashMessageMixin
|
||||
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
|
@ -86,6 +85,8 @@ class WeComQRBindView(WeComQRMixin, View):
|
|||
|
||||
def get(self, request: HttpRequest):
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
query_string = request.GET.urlencode()
|
||||
redirect_url = f'{redirect_url}?{query_string}'
|
||||
redirect_uri = reverse('authentication:wecom-qr-bind-callback', external=True)
|
||||
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import pycountry
|
|||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumbers import PhoneMetadata
|
||||
from common.utils import lazyproperty
|
||||
|
||||
ADMIN = 'Admin'
|
||||
USER = 'User'
|
||||
|
@ -72,7 +73,38 @@ class Language(models.TextChoices):
|
|||
en = 'en', 'English'
|
||||
zh_hans = 'zh-hans', '中文(简体)'
|
||||
zh_hant = 'zh-hant', '中文(繁體)'
|
||||
jp = 'ja', '日本語',
|
||||
ja = 'ja', '日本語',
|
||||
pt_br = 'pt-br', 'Português (Brasil)'
|
||||
|
||||
@classmethod
|
||||
def get_code_mapper(cls):
|
||||
code_mapper = {code: code for code, name in cls.choices}
|
||||
code_mapper.update({
|
||||
'zh': cls.zh_hans.value,
|
||||
'zh-cn': cls.zh_hans.value,
|
||||
'zh-tw': cls.zh_hant.value,
|
||||
'zh-hk': cls.zh_hant.value,
|
||||
})
|
||||
return code_mapper
|
||||
|
||||
@classmethod
|
||||
def to_internal_code(cls, code: str, default='en', with_filename=False):
|
||||
code_mapper = cls.get_code_mapper()
|
||||
code = code.lower()
|
||||
code = code_mapper.get(code) or code_mapper.get(default)
|
||||
if with_filename:
|
||||
cf_mapper = {
|
||||
cls.zh_hans.value: 'zh',
|
||||
}
|
||||
code = cf_mapper.get(code, code)
|
||||
code = code.replace('-', '_')
|
||||
return code
|
||||
|
||||
@classmethod
|
||||
def get_other_codes(cls, code):
|
||||
code_mapper = cls.get_code_mapper()
|
||||
other_codes = [other_code for other_code, _code in code_mapper.items() if code == _code]
|
||||
return other_codes
|
||||
|
||||
|
||||
COUNTRY_CALLING_CODES = get_country_phone_choices()
|
||||
|
|
|
@ -16,4 +16,4 @@ POST_CLEAR = 'post_clear'
|
|||
POST_PREFIX = 'post'
|
||||
PRE_PREFIX = 'pre'
|
||||
|
||||
SKIP_SIGNAL = 'skip_signal'
|
||||
OP_LOG_SKIP_SIGNAL = 'operate_log_skip_signal'
|
||||
|
|
|
@ -17,7 +17,7 @@ from django.db.models import F, ExpressionWrapper, CASCADE
|
|||
from django.db.models import QuerySet
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from ..const.signals import SKIP_SIGNAL
|
||||
from ..const.signals import OP_LOG_SKIP_SIGNAL
|
||||
|
||||
|
||||
class ChoicesMixin:
|
||||
|
@ -83,7 +83,7 @@ def CASCADE_SIGNAL_SKIP(collector, field, sub_objs, using):
|
|||
# 级联删除时,操作日志标记不保存,以免用户混淆
|
||||
try:
|
||||
for obj in sub_objs:
|
||||
setattr(obj, SKIP_SIGNAL, True)
|
||||
setattr(obj, OP_LOG_SKIP_SIGNAL, True)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
|
|
@ -73,7 +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/face/context/",
|
||||
"/api/v1/authentication/mfa/send-code/",
|
||||
"/api/v1/authentication/sso/login/",
|
||||
"/api/v1/authentication/user-session/",
|
||||
|
|
|
@ -1,68 +1,74 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
|
||||
import boto3
|
||||
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.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.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(),
|
||||
)
|
||||
self.endpoint = config.get("ENDPOINT", None)
|
||||
|
||||
try:
|
||||
self.client = self.conn.get_bucket(bucket_name=self.bucket)
|
||||
except Exception:
|
||||
self.client = None
|
||||
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:
|
||||
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)
|
||||
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:
|
||||
return self.client.get_key(path)
|
||||
except Exception:
|
||||
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 'ceph'
|
||||
|
|
|
@ -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', '')
|
||||
|
|
|
@ -18,9 +18,8 @@ def random_ip():
|
|||
return socket.inet_ntoa(struct.pack('>I', random.randint(1, 0xffffffff)))
|
||||
|
||||
|
||||
def random_replace_char(s, chars, length):
|
||||
def random_replace_char(seq, chars, length):
|
||||
using_index = set()
|
||||
seq = list(s)
|
||||
|
||||
while length > 0:
|
||||
index = secrets.randbelow(len(seq) - 1)
|
||||
|
@ -29,7 +28,7 @@ def random_replace_char(s, chars, length):
|
|||
seq[index] = secrets.choice(chars)
|
||||
using_index.add(index)
|
||||
length -= 1
|
||||
return ''.join(seq)
|
||||
return seq
|
||||
|
||||
|
||||
def remove_exclude_char(s, exclude_chars):
|
||||
|
@ -47,19 +46,40 @@ def random_string(
|
|||
if length < 4:
|
||||
raise ValueError('The length of the string must be greater than 3')
|
||||
|
||||
chars_map = (
|
||||
(lower, string.ascii_lowercase),
|
||||
(upper, string.ascii_uppercase),
|
||||
(digit, string.digits),
|
||||
)
|
||||
chars = ''.join([i[1] for i in chars_map if i[0]])
|
||||
chars = remove_exclude_char(chars, exclude_chars)
|
||||
texts = list(secrets.choice(chars) for __ in range(length))
|
||||
texts = ''.join(texts)
|
||||
char_list = []
|
||||
if lower:
|
||||
|
||||
lower_chars = remove_exclude_char(string.ascii_lowercase, exclude_chars)
|
||||
if not lower_chars:
|
||||
raise ValueError('After excluding characters, no lowercase letters are available.')
|
||||
char_list.append(lower_chars)
|
||||
|
||||
if upper:
|
||||
upper_chars = remove_exclude_char(string.ascii_uppercase, exclude_chars)
|
||||
if not upper_chars:
|
||||
raise ValueError('After excluding characters, no uppercase letters are available.')
|
||||
char_list.append(upper_chars)
|
||||
|
||||
if digit:
|
||||
digit_chars = remove_exclude_char(string.digits, exclude_chars)
|
||||
if not digit_chars:
|
||||
raise ValueError('After excluding characters, no digits are available.')
|
||||
char_list.append(digit_chars)
|
||||
|
||||
secret_chars = [secrets.choice(chars) for chars in char_list]
|
||||
|
||||
all_chars = ''.join(char_list)
|
||||
|
||||
remaining_length = length - len(secret_chars)
|
||||
seq = [secrets.choice(all_chars) for _ in range(remaining_length)]
|
||||
|
||||
# 控制一下特殊字符的数量, 别随机出来太多
|
||||
if special_char:
|
||||
symbols = remove_exclude_char(symbols, exclude_chars)
|
||||
special_chars = remove_exclude_char(symbols, exclude_chars)
|
||||
if not special_chars:
|
||||
raise ValueError('After excluding characters, no special characters are available.')
|
||||
symbol_num = length // 16 + 1
|
||||
texts = random_replace_char(texts, symbols, symbol_num)
|
||||
return texts
|
||||
seq = random_replace_char(seq, symbols, symbol_num)
|
||||
secret_chars += seq
|
||||
|
||||
secrets.SystemRandom().shuffle(secret_chars)
|
||||
return ''.join(secret_chars)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
#
|
||||
from django.http import HttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.generic.base import TemplateView
|
||||
|
||||
|
@ -14,11 +15,11 @@ class FlashMessageMsgView(TemplateView):
|
|||
def get(self, request, *args, **kwargs):
|
||||
code = request.GET.get('code')
|
||||
if not code:
|
||||
return HttpResponse('Not found the code')
|
||||
return HttpResponse(_('Not found the code'))
|
||||
|
||||
message_data = FlashMessageUtil.get_message_by_code(code)
|
||||
if not message_data:
|
||||
return HttpResponse('Message code error')
|
||||
return HttpResponse(_('The message code provided is invalid or has expired'))
|
||||
|
||||
items = ('title', 'message', 'error', 'redirect_url', 'confirm_button', 'cancel_url')
|
||||
title, msg, error, redirect_url, confirm_btn, cancel_url = bulk_get(message_data, items)
|
||||
|
|
|
@ -11,8 +11,8 @@ class BaseTranslateManager:
|
|||
SEPARATOR = "<SEP>"
|
||||
LANG_MAPPER = {
|
||||
'ja': 'Japanese',
|
||||
'zh_hant': 'Traditional Chinese',
|
||||
# 'en': 'English',
|
||||
'zh_Hant': 'Traditional Chinese',
|
||||
'pt_BR': 'Portuguese (Brazil)',
|
||||
}
|
||||
|
||||
def __init__(self, dir_path, oai_trans_instance):
|
||||
|
|
|
@ -37,7 +37,6 @@ class CoreTranslateManager(BaseTranslateManager):
|
|||
zh_dict = {entry.msgid: entry.msgstr for entry in po.translated_entries()}
|
||||
|
||||
for file_prefix, target_lang in self.LANG_MAPPER.items():
|
||||
file_prefix = 'zh_Hant' if file_prefix == 'zh_hant' else file_prefix
|
||||
po_file_path = os.path.join(self._dir, file_prefix, 'LC_MESSAGES', 'django.po')
|
||||
trans_po = polib.pofile(po_file_path)
|
||||
need_trans_dict = self.get_need_trans_dict(zh_dict, trans_po)
|
||||
|
|
|
@ -36,6 +36,7 @@ class OtherTranslateManager(BaseTranslateManager):
|
|||
zh_dict = self.load_json_as_dict()
|
||||
|
||||
for file_prefix, target_lang in self.LANG_MAPPER.items():
|
||||
file_prefix = file_prefix.lower()
|
||||
other_dict = self.load_json_as_dict(file_prefix)
|
||||
need_trans_dict = self.get_need_trans_dict(zh_dict, other_dict)
|
||||
print(f'{GREEN}Translate: {self.dir_name} {file_prefix} need to translate '
|
||||
|
|
|
@ -26,7 +26,7 @@ class OpenAITranslate:
|
|||
"content": text,
|
||||
},
|
||||
],
|
||||
model="gpt-4",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
except Exception as e:
|
||||
print("Open AI Error: ", e)
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
{
|
||||
"ACLRejectError": "Este comando não é permitido ser executado",
|
||||
"AffectedRows": "Linhas afetadas",
|
||||
"AlreadyFirstPageError": "Já é a primeira página",
|
||||
"AlreadyLastPageError": "Já é a última página",
|
||||
"Cancel": "Cancelar",
|
||||
"ChangeContextError": "Falha ao alternar o contexto",
|
||||
"CommandReview": "Revisão de Comando",
|
||||
"CommandReviewMessage": "O comando que você digitou precisa ser revisado antes de executar, gostaria de iniciar a solicitação de revisão?",
|
||||
"CommandReviewRejectBy": "O comando de revisão foi recusado por %s",
|
||||
"CommandReviewTimeoutError": "Tempo limite para revisão de comando",
|
||||
"CommandWarningDialogMessage": "O comando que você está tentando executar tem riscos, um aviso será enviado para o administrador. Deseja continuar?",
|
||||
"Confirm": "Confirmar",
|
||||
"ConnectError": "Falha na conexão",
|
||||
"ConnectSuccess": "Conectado com sucesso",
|
||||
"Connected": "Conectado",
|
||||
"CopyNotAllowed": "Cópia não permitida, por favor contate o administrador para liberar a permissão!",
|
||||
"Current": "Atual",
|
||||
"DatabaseExplorer": "Navegador de Banco de Dados",
|
||||
"DatabaseProperties": "Propriedades da fonte de dados",
|
||||
"DriverClass": "Classe do driver",
|
||||
"DriverVersion": "Versão do driver",
|
||||
"ErrorMessage": "Mensagem de erro",
|
||||
"ExecuteError": "Falha na execução",
|
||||
"ExecuteSuccess": "Execução bem-sucedida",
|
||||
"ExecutionCanceled": "Execução cancelada",
|
||||
"ExportALL": "Exportar todos os dados",
|
||||
"ExportAll": "Exportar tudo",
|
||||
"ExportCurrent": "Exportar página atual",
|
||||
"ExportData": "Exportar dados",
|
||||
"FetchError": "Falha ao obter os dados",
|
||||
"FormatHotKey": "Formatar (Ctrl + L)",
|
||||
"InitializeDatasource": "Inicializar fonte de dados",
|
||||
"InitializeDatasourceFailed": "Falha ao inicializar a fonte de dados",
|
||||
"InitializingDatasourceMessage": "Inicializando a fonte de dados, por favor aguarde...",
|
||||
"JDBCURL": "URL JDBC",
|
||||
"LogOutput": "Log de saída",
|
||||
"Name": "Nome",
|
||||
"NewQuery": "Nova consulta",
|
||||
"NoPermissionError": "Não tem permissão para executar esta Action",
|
||||
"NumRow": "{num} linhas",
|
||||
"Open": "Aberto",
|
||||
"OverMaxIdleTimeError": "Esta sessão foi encerrada pois o tempo ocioso foi maior que %d minutos",
|
||||
"OverMaxSessionTimeError": "Esta sessão foi encerrada por ter excedido %d horas",
|
||||
"ParseError": "Falha ao processar",
|
||||
"PasteNotAllowed": "Colagem não permitida, por favor contate o administrador para liberar a permissão!",
|
||||
"PermissionAlreadyExpired": "Autorização expirada",
|
||||
"PermissionExpiredDialogMessage": "A autorização expirou, a sessão expira em dez minutos, por favor entre em contato com o administrador para renovação",
|
||||
"PermissionExpiredDialogTitle": "A autorização expirou",
|
||||
"PermissionsExpiredOn": "As permissões associadas a esta sessão expiraram em %s",
|
||||
"Properties": "Propriedades",
|
||||
"Refresh": "Atualizar",
|
||||
"Run": "Executar",
|
||||
"RunHotKey": "Executar (Ctrl + Enter)",
|
||||
"RunSelected": "Executar selecionado",
|
||||
"Save": "Salvar",
|
||||
"SaveSQL": "Salve o SQL",
|
||||
"SaveSucceed": "Salvo com sucesso",
|
||||
"SelectSQL": "Selecionar SQL",
|
||||
"SessionClosedBy": "A sessão foi fechada por %s",
|
||||
"SessionFinished": "A sessão terminou",
|
||||
"SessionLockedError": "A sessão atual está bloqueada, não é possível continuar com o comando",
|
||||
"SessionLockedMessage": "Esta sessão foi bloqueada por %s, não é possível continuar com a ação",
|
||||
"SessionUnlockedMessage": "Esta sessão foi desbloqueada por %s, comandos agora podem ser executados",
|
||||
"ShowProperties": "Propriedades",
|
||||
"StopHotKey": "Parar (Ctrl + C)",
|
||||
"Submit": "Submeter",
|
||||
"Total": "Total",
|
||||
"Type": "Escrever",
|
||||
"User": "Usuário",
|
||||
"UserCancelCommandReviewError": "O usuário cancelou a revisão do comando",
|
||||
"Version": "Versão",
|
||||
"ViewData": "Ver dados",
|
||||
"WaitCommandReviewMessage": "Solicitação de revisão enviada, aguarde os resultados da revisão",
|
||||
"Warning": "Aviso",
|
||||
"initializingDatasourceFailedMessage": "Falha na conexão, verifique se a configuração de conexão do banco de dados está correta"
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue