Compare commits

...

7 Commits

Author SHA1 Message Date
fit2bot
df05973da3 feat: Update v3.5.7 2023-09-26 19:19:20 +08:00
ibuler
d2a4e79b31 fix: pubkey auth require svc sign 2023-09-25 23:31:08 +08:00
ibuler
f65e2ac15f fix: 修复暴力校验验证码 2023-09-25 23:06:00 +08:00
Bai
96f69c821b fix: 修复系统用户同步同时包含pwd/ssh-key导致创建账号id冲突报错的问题 2023-09-25 16:24:19 +08:00
Bai
13fffa52dd fix: 解决节点资产数量方法计算不准确的问题 2023-09-22 15:19:12 +08:00
Aaron3S
fe37913ed9 perf: 优化 Playbook 文件创建逻辑 2023-09-19 18:48:43 +08:00
ibuler
4009457000 fix: 修复 random error 2023-09-19 18:19:13 +08:00
9 changed files with 107 additions and 53 deletions

1
GITSHA Normal file
View File

@@ -0,0 +1 @@
d2a4e79b3191ef7ff2ba194c47aa6373bf8c2a3b

View File

@@ -25,7 +25,7 @@ def migrate_asset_accounts(apps, schema_editor):
count += len(auth_books) count += len(auth_books)
# auth book 和 account 相同的属性 # auth book 和 account 相同的属性
same_attrs = [ same_attrs = [
'id', 'username', 'comment', 'date_created', 'date_updated', 'username', 'comment', 'date_created', 'date_updated',
'created_by', 'asset_id', 'org_id', 'created_by', 'asset_id', 'org_id',
] ]
# 认证的属性,可能是 auth_book 的,可能是 system_user 的 # 认证的属性,可能是 auth_book 的,可能是 system_user 的

View File

@@ -403,12 +403,7 @@ class NodeAssetsMixin(NodeAllAssetsMappingMixin):
return Asset.objects.filter(q).distinct() return Asset.objects.filter(q).distinct()
def get_assets_amount(self): def get_assets_amount(self):
q = Q(node__key__startswith=f'{self.key}:') | Q(node__key=self.key) return self.get_all_assets().count()
return self.assets.through.objects.filter(q).count()
def get_assets_account_by_children(self):
children = self.get_all_children().values_list()
return self.assets.through.objects.filter(node_id__in=children).count()
@classmethod @classmethod
def get_node_all_assets_by_key_v2(cls, key): def get_node_all_assets_by_key_v2(cls, key):

View File

@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from django.contrib.auth import get_user_model
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from common.permissions import ServiceAccountSignaturePermission
from .base import JMSBaseAuthBackend from .base import JMSBaseAuthBackend
UserModel = get_user_model() UserModel = get_user_model()
@@ -18,6 +19,10 @@ class PublicKeyAuthBackend(JMSBaseAuthBackend):
def authenticate(self, request, username=None, public_key=None, **kwargs): def authenticate(self, request, username=None, public_key=None, **kwargs):
if not public_key: if not public_key:
return None return None
permission = ServiceAccountSignaturePermission()
if not permission.has_permission(request, None):
return None
if username is None: if username is None:
username = kwargs.get(UserModel.USERNAME_FIELD) username = kwargs.get(UserModel.USERNAME_FIELD)
try: try:
@@ -26,7 +31,7 @@ class PublicKeyAuthBackend(JMSBaseAuthBackend):
return None return None
else: else:
if user.check_public_key(public_key) and \ if user.check_public_key(public_key) and \
self.user_can_authenticate(user): self.user_can_authenticate(user):
return user return user
def get_user(self, user_id): def get_user(self, user_id):

View File

@@ -86,3 +86,38 @@ class UserConfirmation(permissions.BasePermission):
min_level = ConfirmType.values.index(confirm_type) + 1 min_level = ConfirmType.values.index(confirm_type) + 1
name = 'UserConfirmationLevel{}TTL{}'.format(min_level, ttl) name = 'UserConfirmationLevel{}TTL{}'.format(min_level, ttl)
return type(name, (cls,), {'min_level': min_level, 'ttl': ttl, 'confirm_type': confirm_type}) return type(name, (cls,), {'min_level': min_level, 'ttl': ttl, 'confirm_type': confirm_type})
class ServiceAccountSignaturePermission(permissions.BasePermission):
def has_permission(self, request, view):
from authentication.models import AccessKey
from common.utils.crypto import get_aes_crypto
signature = request.META.get('HTTP_X_JMS_SVC', '')
if not signature or not signature.startswith('Sign'):
return False
data = signature[4:].strip()
if not data or ':' not in data:
return False
ak_id, time_sign = data.split(':', 1)
if not ak_id or not time_sign:
return False
ak = AccessKey.objects.filter(id=ak_id).first()
if not ak or not ak.is_active:
return False
if not ak.user or not ak.user.is_active or not ak.user.is_service_account:
return False
aes = get_aes_crypto(str(ak.secret).replace('-', ''), mode='ECB')
try:
timestamp = aes.decrypt(time_sign)
if not timestamp or not timestamp.isdigit():
return False
timestamp = int(timestamp)
interval = abs(int(time.time()) - timestamp)
if interval > 30:
return False
return True
except Exception:
return False
def has_object_permission(self, request, view, obj):
return False

View File

@@ -1,10 +1,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import struct
import random import random
import socket import socket
import string import string
import struct
string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~' string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_~'
@@ -19,6 +18,7 @@ def random_ip():
def random_string(length: int, lower=True, upper=True, digit=True, special_char=False): def random_string(length: int, lower=True, upper=True, digit=True, special_char=False):
random.seed(None)
args_names = ['lower', 'upper', 'digit', 'special_char'] args_names = ['lower', 'upper', 'digit', 'special_char']
args_values = [lower, upper, digit, special_char] args_values = [lower, upper, digit, special_char]
args_string = [string.ascii_lowercase, string.ascii_uppercase, string.digits, string_punctuation] args_string = [string.ascii_lowercase, string.ascii_uppercase, string.digits, string_punctuation]

View File

@@ -26,6 +26,7 @@ class SendAndVerifyCodeUtil(object):
self.target = target self.target = target
self.backend = backend self.backend = backend
self.key = key or self.KEY_TMPL.format(target) self.key = key or self.KEY_TMPL.format(target)
self.verify_key = self.key + '_verify'
self.timeout = settings.VERIFY_CODE_TTL if timeout is None else timeout self.timeout = settings.VERIFY_CODE_TTL if timeout is None else timeout
self.other_args = kwargs self.other_args = kwargs
@@ -47,6 +48,11 @@ class SendAndVerifyCodeUtil(object):
raise raise
def verify(self, code): def verify(self, code):
times = cache.get(self.verify_key, 0)
if times >= 3:
self.__clear()
raise CodeExpired
cache.set(self.verify_key, times + 1, timeout=self.timeout)
right = cache.get(self.key) right = cache.get(self.key)
if not right: if not right:
raise CodeExpired raise CodeExpired
@@ -59,6 +65,7 @@ class SendAndVerifyCodeUtil(object):
def __clear(self): def __clear(self):
cache.delete(self.key) cache.delete(self.key)
cache.delete(self.verify_key)
def __ttl(self): def __ttl(self):
return cache.ttl(self.key) return cache.ttl(self.key)

View File

@@ -3,6 +3,7 @@ import shutil
import zipfile import zipfile
from django.conf import settings from django.conf import settings
from django.core.exceptions import SuspiciousFileOperation
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from rest_framework import status from rest_framework import status
@@ -18,6 +19,7 @@ __all__ = ["PlaybookViewSet", "PlaybookFileBrowserAPIView"]
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from django.utils._os import safe_join
def unzip_playbook(src, dist): def unzip_playbook(src, dist):
@@ -37,7 +39,7 @@ class PlaybookViewSet(OrgBulkModelViewSet):
raise JMSException(code='playbook_has_job', detail={"msg": _("Currently playbook is being used in a job")}) raise JMSException(code='playbook_has_job', detail={"msg": _("Currently playbook is being used in a job")})
instance_id = instance.id instance_id = instance.id
super().perform_destroy(instance) super().perform_destroy(instance)
dest_path = os.path.join(settings.DATA_DIR, "ops", "playbook", instance_id.__str__()) dest_path = safe_join(settings.DATA_DIR, "ops", "playbook", instance_id.__str__())
shutil.rmtree(dest_path) shutil.rmtree(dest_path)
def get_queryset(self): def get_queryset(self):
@@ -48,8 +50,8 @@ class PlaybookViewSet(OrgBulkModelViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
instance = serializer.save() instance = serializer.save()
if 'multipart/form-data' in self.request.headers['Content-Type']: if 'multipart/form-data' in self.request.headers['Content-Type']:
src_path = os.path.join(settings.MEDIA_ROOT, instance.path.name) src_path = safe_join(settings.MEDIA_ROOT, instance.path.name)
dest_path = os.path.join(settings.DATA_DIR, "ops", "playbook", instance.id.__str__()) dest_path = safe_join(settings.DATA_DIR, "ops", "playbook", instance.id.__str__())
try: try:
unzip_playbook(src_path, dest_path) unzip_playbook(src_path, dest_path)
except RuntimeError as e: except RuntimeError as e:
@@ -60,9 +62,9 @@ class PlaybookViewSet(OrgBulkModelViewSet):
else: else:
if instance.create_method == 'blank': if instance.create_method == 'blank':
dest_path = os.path.join(settings.DATA_DIR, "ops", "playbook", instance.id.__str__()) dest_path = safe_join(settings.DATA_DIR, "ops", "playbook", instance.id.__str__())
os.makedirs(dest_path) os.makedirs(dest_path)
with open(os.path.join(dest_path, 'main.yml'), 'w') as f: with open(safe_join(dest_path, 'main.yml'), 'w') as f:
f.write('## write your playbook here') f.write('## write your playbook here')
@@ -83,13 +85,15 @@ class PlaybookFileBrowserAPIView(APIView):
work_path = playbook.work_dir work_path = playbook.work_dir
file_key = request.query_params.get('key', '') file_key = request.query_params.get('key', '')
if file_key: if file_key:
file_path = os.path.join(work_path, file_key) try:
with open(file_path, 'r') as f: file_path = safe_join(work_path, file_key)
try: with open(file_path, 'r') as f:
content = f.read() content = f.read()
except UnicodeDecodeError: except UnicodeDecodeError:
content = _('Unsupported file content') content = _('Unsupported file content')
return Response({'content': content}) except SuspiciousFileOperation:
raise JMSException(code='invalid_file_path', detail={"msg": _("Invalid file path")})
return Response({'content': content})
else: else:
expand_key = request.query_params.get('expand', '') expand_key = request.query_params.get('expand', '')
nodes = self.generate_tree(playbook, work_path, expand_key) nodes = self.generate_tree(playbook, work_path, expand_key)
@@ -105,7 +109,8 @@ class PlaybookFileBrowserAPIView(APIView):
parent_key = '' parent_key = ''
if os.path.dirname(parent_key) == 'root': if os.path.dirname(parent_key) == 'root':
parent_key = os.path.basename(parent_key) parent_key = os.path.basename(parent_key)
full_path = os.path.join(work_path, parent_key)
full_path = safe_join(work_path, parent_key)
is_directory = request.data.get('is_directory', False) is_directory = request.data.get('is_directory', False)
content = request.data.get('content', '') content = request.data.get('content', '')
@@ -117,27 +122,30 @@ class PlaybookFileBrowserAPIView(APIView):
p = 'new_file.yml' p = 'new_file.yml'
else: else:
p = 'new_dir' p = 'new_dir'
np = os.path.join(full_path, p) np = safe_join(full_path, p)
n = 0 n = 0
while os.path.exists(np): while os.path.exists(np):
n += 1 n += 1
np = os.path.join(full_path, '{}({})'.format(p, n)) np = safe_join(full_path, '{}({})'.format(p, n))
return np return np
if is_directory: try:
new_file_path = find_new_name(name) if is_directory:
os.makedirs(new_file_path) new_file_path = find_new_name(name)
else: os.makedirs(new_file_path)
new_file_path = find_new_name(name, True) else:
with open(new_file_path, 'w') as f: new_file_path = find_new_name(name, True)
f.write(content) with open(new_file_path, 'w') as f:
f.write(content)
except SuspiciousFileOperation:
raise JMSException(code='invalid_file_path', detail={"msg": _("Invalid file path")})
relative_path = os.path.relpath(os.path.dirname(new_file_path), work_path) relative_path = os.path.relpath(os.path.dirname(new_file_path), work_path)
new_node = { new_node = {
"name": os.path.basename(new_file_path), "name": os.path.basename(new_file_path),
"title": os.path.basename(new_file_path), "title": os.path.basename(new_file_path),
"id": os.path.join(relative_path, os.path.basename(new_file_path)) "id": safe_join(relative_path, os.path.basename(new_file_path))
if not os.path.join(relative_path, os.path.basename(new_file_path)).startswith('.') if not safe_join(relative_path, os.path.basename(new_file_path)).startswith('.')
else os.path.basename(new_file_path), else os.path.basename(new_file_path),
"isParent": is_directory, "isParent": is_directory,
"pId": relative_path if not relative_path.startswith('.') else 'root', "pId": relative_path if not relative_path.startswith('.') else 'root',
@@ -156,7 +164,7 @@ class PlaybookFileBrowserAPIView(APIView):
new_name = request.data.get('new_name', '') new_name = request.data.get('new_name', '')
if file_key in self.protected_files and new_name: if file_key in self.protected_files and new_name:
return Response({'msg': '{} can not be rename'.format(file_key)}, status=status.HTTP_400_BAD_REQUEST) raise JMSException(code='this_file_can_not_be_rename', detail={"msg": _("This file can not be rename")})
if os.path.dirname(file_key) == 'root': if os.path.dirname(file_key) == 'root':
file_key = os.path.basename(file_key) file_key = os.path.basename(file_key)
@@ -166,16 +174,20 @@ class PlaybookFileBrowserAPIView(APIView):
if not file_key or file_key == 'root': if not file_key or file_key == 'root':
return Response(status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_400_BAD_REQUEST)
file_path = os.path.join(work_path, file_key) file_path = safe_join(work_path, file_key)
# rename # rename
if new_name: if new_name:
new_file_path = os.path.join(os.path.dirname(file_path), new_name) try:
if new_file_path == file_path: new_file_path = safe_join(os.path.dirname(file_path), new_name)
return Response(status=status.HTTP_200_OK) if new_file_path == file_path:
if os.path.exists(new_file_path): return Response(status=status.HTTP_200_OK)
return Response({'msg': '{} already exists'.format(new_name)}, status=status.HTTP_400_BAD_REQUEST) if os.path.exists(new_file_path):
os.rename(file_path, new_file_path) raise JMSException(code='file_already exists', detail={"msg": _("File already exists")})
os.rename(file_path, new_file_path)
except SuspiciousFileOperation:
raise JMSException(code='invalid_file_path', detail={"msg": _("Invalid file path")})
# edit content # edit content
else: else:
if not is_directory: if not is_directory:
@@ -189,10 +201,11 @@ class PlaybookFileBrowserAPIView(APIView):
work_path = playbook.work_dir work_path = playbook.work_dir
file_key = request.query_params.get('key', '') file_key = request.query_params.get('key', '')
if not file_key: if not file_key:
return Response({'msg': 'key is required'}, status=status.HTTP_400_BAD_REQUEST) raise JMSException(code='file_key_is_required', detail={"msg": _("File key is required")})
if file_key in self.protected_files: if file_key in self.protected_files:
return Response({'msg': ' {} can not be delete'.format(file_key)}, status=status.HTTP_400_BAD_REQUEST) raise JMSException(code='This file can not be delete', detail={"msg": _("This file can not be delete")})
file_path = os.path.join(work_path, file_key) file_path = safe_join(work_path, file_key)
if os.path.isdir(file_path): if os.path.isdir(file_path):
shutil.rmtree(file_path) shutil.rmtree(file_path)
else: else:
@@ -216,11 +229,12 @@ class PlaybookFileBrowserAPIView(APIView):
relative_path = os.path.relpath(path, root_path) relative_path = os.path.relpath(path, root_path)
for d in dirs: for d in dirs:
dir_id = os.path.relpath(safe_join(path, d), root_path)
node = { node = {
"name": d, "name": d,
"title": d, "title": d,
"id": os.path.join(relative_path, d) if not os.path.join(relative_path, d).startswith( "id": dir_id,
'.') else d,
"isParent": True, "isParent": True,
"open": True, "open": True,
"pId": relative_path if not relative_path.startswith('.') else 'root', "pId": relative_path if not relative_path.startswith('.') else 'root',
@@ -230,12 +244,12 @@ class PlaybookFileBrowserAPIView(APIView):
node['open'] = True node['open'] = True
nodes.append(node) nodes.append(node)
for f in files: for f in files:
file_id = os.path.relpath(safe_join(path, f), root_path)
node = { node = {
"name": f, "name": f,
"title": f, "title": f,
"iconSkin": 'file', "iconSkin": 'file',
"id": os.path.join(relative_path, f) if not os.path.join(relative_path, f).startswith( "id": file_id,
'.') else f,
"isParent": False, "isParent": False,
"open": False, "open": False,
"pId": relative_path if not relative_path.startswith('.') else 'root', "pId": relative_path if not relative_path.startswith('.') else 'root',

View File

@@ -3,8 +3,6 @@
# #
import base64 import base64
import datetime import datetime
import random
import string
import uuid import uuid
from typing import Callable from typing import Callable
@@ -562,8 +560,7 @@ class TokenMixin:
return self.access_keys.first() return self.access_keys.first()
def generate_reset_token(self): def generate_reset_token(self):
letter = string.ascii_letters + string.digits token = random_string(50)
token = ''.join([random.choice(letter) for _ in range(50)])
self.set_cache(token) self.set_cache(token)
return token return token