Merge pull request #14697 from jumpserver/dev

v4.5.0
pull/14701/head
Bryan 2024-12-19 15:57:11 +08:00 committed by GitHub
commit f3acc28ded
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
171 changed files with 17810 additions and 3025 deletions

24
.github/workflows/docs-release.yml vendored Normal file
View File

@ -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

40
.github/workflows/translate-readme.yml vendored Normal file
View File

@ -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 }}

View File

@ -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=" \

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

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

View File

@ -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'),
)

View File

@ -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)}')

View File

@ -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)

View File

@ -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}')

View File

@ -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):

View File

@ -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

View File

View File

@ -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)

View File

@ -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

View File

@ -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}')

View File

@ -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

View File

View File

@ -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}')

View File

@ -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')

View File

@ -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.'
)

View File

@ -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})'

View File

@ -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

View File

@ -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',

View File

@ -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):

View File

@ -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):

View File

@ -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')

View File

@ -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):

View File

@ -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()

View File

@ -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())

View File

@ -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)

View File

@ -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 = {

View File

@ -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)

View File

@ -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

View File

@ -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')

View File

@ -15,3 +15,4 @@ from .ssh_key import *
from .sso import *
from .temp_token import *
from .token import *
from .face import *

View File

@ -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)

View File

@ -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

View File

@ -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 原来的名字

View File

@ -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

View File

@ -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'),
]

View File

@ -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')

View File

@ -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:

View File

@ -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()

View File

@ -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')
]

View File

@ -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', ]

View File

@ -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

View File

@ -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)

View File

@ -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'),
]

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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'),
]

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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")

View File

@ -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:

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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, '')

View File

@ -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:

View File

@ -4,3 +4,4 @@ from .connection_token import *
from .password_mfa import *
from .ssh_key import *
from .token import *
from .face import *

View File

@ -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},
}

View File

@ -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')},

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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'),

View File

@ -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()

View File

@ -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)

View File

@ -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,

View File

@ -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', '')

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -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})

View File

@ -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()

View File

@ -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'

View File

@ -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

View File

@ -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/",

View File

@ -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'

View File

@ -158,7 +158,10 @@ def is_uuid(seq):
def get_request_ip(request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')
if x_forwarded_for and x_forwarded_for[0]:
login_ip = x_forwarded_for[0].split(":")[0]
login_ip = x_forwarded_for[0]
if login_ip.count(':') == 1:
# format: ipv4:port (非标准格式的 X-Forwarded-For)
login_ip = login_ip.split(":")[0]
return login_ip
login_ip = request.META.get('REMOTE_ADDR', '')

View File

@ -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)

View File

@ -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)

View File

@ -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):

View File

@ -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)

View File

@ -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 '

View File

@ -26,7 +26,7 @@ class OpenAITranslate:
"content": text,
},
],
model="gpt-4",
model="gpt-4o-mini",
)
except Exception as e:
print("Open AI Error: ", e)

77
apps/i18n/chen/pt_br.json Normal file
View File

@ -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