diff --git a/archery/settings.py b/archery/settings.py index be57e358..97725efe 100644 --- a/archery/settings.py +++ b/archery/settings.py @@ -26,14 +26,10 @@ ), # Reference: https://docs.djangoproject.com/en/4.0/ref/settings/#secret-key DATABASE_URL=(str, "mysql://root:@127.0.0.1:3306/archery"), CACHE_URL=(str, "redis://127.0.0.1:6379/0"), - # External authentication currently supports LDAP, OIDC, and DINGDING. - # Only one method should be enabled. If multiple are enabled, only one takes effect with priority LDAP > DINGDING > OIDC. + # External authentication currently supports LDAP and OIDC. + # Only one method should be enabled. If multiple are enabled, only one takes effect with priority LDAP > OIDC. ENABLE_LDAP=(bool, False), ENABLE_OIDC=(bool, False), - ENABLE_DINGDING=( - bool, - False, - ), # DingTalk authentication reference: https://open.dingtalk.com/document/orgapp/tutorial-obtaining-user-personal-information AUTH_LDAP_ALWAYS_UPDATE_USER=(bool, True), AUTH_LDAP_USER_ATTR_MAP=( dict, @@ -72,8 +68,6 @@ ENABLED_NOTIFIERS=( list, [ - "sql.notify:DingdingWebhookNotifier", - "sql.notify:DingdingPersonNotifier", "sql.notify:FeishuWebhookNotifier", "sql.notify:FeishuPersonNotifier", "sql.notify:QywxWebhookNotifier", @@ -84,6 +78,7 @@ ), CURRENT_AUDITOR=(str, "sql.utils.workflow_audit:AuditV2"), PASSWORD_MIXIN_PATH=(str, "sql.plugins.password:DummyMixin"), + FIELD_ENCRYPTION_KEYS=(str, ""), ) # SECURITY WARNING: keep the secret key used in production secret! @@ -129,6 +124,8 @@ PASSWORD_MIXIN_PATH = env("PASSWORD_MIXIN_PATH") +FIELD_ENCRYPTION_KEYS = env("FIELD_ENCRYPTION_KEYS") + # Application definition INSTALLED_APPS = ( "django.contrib.admin", @@ -343,20 +340,6 @@ LOGIN_REDIRECT_URL = "/" -# Dingding -ENABLE_DINGDING = env("ENABLE_DINGDING", False) -if ENABLE_DINGDING: - INSTALLED_APPS += ("django_auth_dingding",) - AUTHENTICATION_BACKENDS = ( - "common.authenticate.dingding_auth.DingdingAuthenticationBackend", - "django.contrib.auth.backends.ModelBackend", - ) - AUTH_DINGDING_AUTHENTICATION_CALLBACK_URL = env( - "AUTH_DINGDING_AUTHENTICATION_CALLBACK_URL" - ) - AUTH_DINGDING_APP_KEY = env("AUTH_DINGDING_APP_KEY") - AUTH_DINGDING_APP_SECRET = env("AUTH_DINGDING_APP_SECRET") - # LDAP ENABLE_LDAP = env("ENABLE_LDAP", False) if ENABLE_LDAP: @@ -421,7 +404,6 @@ SUPPORTED_AUTHENTICATION = [ ("LDAP", ENABLE_LDAP), - ("DINGDING", ENABLE_DINGDING), ("OIDC", ENABLE_OIDC), ("CAS", ENABLE_CAS), ] @@ -432,7 +414,7 @@ if ENABLE_AUTHENTICATION_COUNT > 0: if ENABLE_AUTHENTICATION_COUNT > 1: logger.warning( - "External authentication currently supports LDAP, DINGDING, OIDC, and CAS. Only one method should be enabled. If multiple are enabled, only one takes effect with priority LDAP > DINGDING > OIDC > CAS." + "External authentication currently supports LDAP, OIDC, and CAS. Only one method should be enabled. If multiple are enabled, only one takes effect with priority LDAP > OIDC > CAS." ) authentication = "" # Empty by default for name, enabled in SUPPORTED_AUTHENTICATION: diff --git a/archery/urls.py b/archery/urls.py index 03d8f556..2bbb206c 100644 --- a/archery/urls.py +++ b/archery/urls.py @@ -25,11 +25,6 @@ path("oidc/", include("mozilla_django_oidc.urls")), ] -if settings.ENABLE_DINGDING: # pragma: no cover - urlpatterns += [ - path("dingding/", include("django_auth_dingding.urls")), - ] - handler400 = views.bad_request handler403 = views.permission_denied handler404 = views.page_not_found diff --git a/common/auth.py b/common/auth.py index 7c248a43..59d1c4cc 100644 --- a/common/auth.py +++ b/common/auth.py @@ -11,9 +11,7 @@ from django.http import HttpResponse, HttpResponseRedirect from django.urls import reverse -from django.conf import settings from common.config import SysConfig -from common.utils.ding_api import get_ding_user_id from sql.models import Users, ResourceGroup, TwoFactorAuthConfig logger = logging.getLogger("default") @@ -162,11 +160,6 @@ def authenticate_entry(request): else: # No 2FA configured; log in directly login(request, authenticated_user) - # Fetch DingTalk user ID for direct notifications - if SysConfig().get( - "ding_to_person" - ) is True and "admin" not in request.POST.get("username"): - get_ding_user_id(request.POST.get("username")) result = {"status": 0, "msg": "ok", "data": None} return HttpResponse(json.dumps(result), content_type="application/json") @@ -221,11 +214,5 @@ def sign_up(request): # Sign out def sign_out(request): - user = request.user logout(request) - # If DingTalk auth is enabled, redirect to DingTalk logout page - if user.ding_user_id and settings.ENABLE_DINGDING: - return HttpResponseRedirect( - redirect_to="https://login.dingtalk.com/oauth2/logout" - ) return HttpResponseRedirect(reverse("sql:login")) diff --git a/common/authenticate/dingding_auth.py b/common/authenticate/dingding_auth.py deleted file mode 100644 index 091ba80b..00000000 --- a/common/authenticate/dingding_auth.py +++ /dev/null @@ -1,10 +0,0 @@ -from django_auth_dingding import auth -from common.auth import init_user - - -class DingdingAuthenticationBackend(auth.DingdingAuthenticationBackend): - def create_user(self, claims): - """Return object for a newly created user account.""" - user = super().create_user(claims) - init_user(user) - return user diff --git a/common/encryption.py b/common/encryption.py new file mode 100644 index 00000000..b028bb47 --- /dev/null +++ b/common/encryption.py @@ -0,0 +1,131 @@ +import base64 +import os +import re +from functools import lru_cache + +from cryptography.fernet import Fernet, MultiFernet +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from django.conf import settings +from django.utils.encoding import force_bytes, force_str + +from common.utils.aes_decryptor import Prpcrypt + +ENCRYPTED_VALUE_PREFIX = "enc1:" +HEX_RE = re.compile(r"^[0-9a-fA-F]+$") + + +class DecryptionError(ValueError): + pass + + +def generate_field_encryption_key(): + return Fernet.generate_key().decode("ascii") + + +def is_encrypted_value(value): + return isinstance(value, str) and value.startswith(ENCRYPTED_VALUE_PREFIX) + + +def encrypt_value(value): + if value is None: + return None + if not _load_field_encryption_keys(): + raise ValueError( + "FIELD_ENCRYPTION_KEYS must be configured before writing encrypted values." + ) + token = get_multi_fernet().encrypt(force_bytes(value)) + return f"{ENCRYPTED_VALUE_PREFIX}{force_str(token)}" + + +def decrypt_value(value): + if value is None: + return None + if not isinstance(value, str): + return value + if is_encrypted_value(value): + token = value[len(ENCRYPTED_VALUE_PREFIX) :] + return force_str(get_multi_fernet().decrypt(force_bytes(token))) + return decrypt_legacy_value(value) + + +def decrypt_legacy_value(value): + if value in ("", None): + return value + + mirage_value = _try_decrypt_mirage(value) + if mirage_value is not None: + return mirage_value + + old_value = _try_decrypt_old_hex(value) + if old_value is not None: + return old_value + + raise DecryptionError( + "Unable to decrypt legacy value. " f"marker={value[:16]!r} length={len(value)}" + ) + + +@lru_cache(maxsize=1) +def get_multi_fernet(): + keys = _load_field_encryption_keys() + if not keys: + raise ValueError( + "FIELD_ENCRYPTION_KEYS must be configured before writing encrypted values." + ) + return MultiFernet([Fernet(key) for key in keys]) + + +def _load_field_encryption_keys(): + raw_keys = getattr(settings, "FIELD_ENCRYPTION_KEYS", "") or os.environ.get( + "FIELD_ENCRYPTION_KEYS", "" + ) + keys = [] + for raw_key in raw_keys.split(","): + key = raw_key.strip() + if not key: + continue + try: + Fernet(key) + except Exception as exc: + raise ValueError("Invalid FIELD_ENCRYPTION_KEYS entry.") from exc + keys.append(key.encode("ascii")) + return keys + + +def _try_decrypt_mirage(value): + try: + key = getattr(settings, "MIRAGE_SECRET_KEY", None) or getattr( + settings, "SECRET_KEY" + ) + if not key: + return None + cipher_key = base64.urlsafe_b64encode(force_bytes(key))[:32] + if len(cipher_key) not in (16, 24, 32): + return None + cipher_mode = getattr(settings, "MIRAGE_CIPHER_MODE", "ECB") + iv = force_bytes(getattr(settings, "MIRAGE_CIPHER_IV", "1234567890abcdef")) + encrypted = base64.urlsafe_b64decode(force_bytes(value)) + if cipher_mode == "CBC": + mode = modes.CBC(iv) + else: + mode = modes.ECB() + decryptor = Cipher( + algorithms.AES(cipher_key), mode, default_backend() + ).decryptor() + unpadder = padding.PKCS7(algorithms.AES(cipher_key).block_size).unpadder() + plaintext = decryptor.update(encrypted) + decryptor.finalize() + unpadded = unpadder.update(plaintext) + unpadder.finalize() + return force_str(unpadded) + except Exception: + return None + + +def _try_decrypt_old_hex(value): + if len(value) < 32 or len(value) % 2 != 0 or HEX_RE.fullmatch(value) is None: + return None + try: + return Prpcrypt().decrypt(value) + except Exception: + return None diff --git a/common/fields/__init__.py b/common/fields/__init__.py new file mode 100644 index 00000000..f51f468b --- /dev/null +++ b/common/fields/__init__.py @@ -0,0 +1,3 @@ +from common.fields.encrypted import EncryptedCharField, EncryptedTextField + +__all__ = ["EncryptedCharField", "EncryptedTextField"] diff --git a/common/fields/encrypted.py b/common/fields/encrypted.py new file mode 100644 index 00000000..16fda63f --- /dev/null +++ b/common/fields/encrypted.py @@ -0,0 +1,36 @@ +from django.core.validators import MaxLengthValidator +from django.db import models + +from common.encryption import decrypt_value, encrypt_value + + +class EncryptedMixin(models.Field): + def __init__(self, *args, **kwargs): + self._plaintext_max_length = kwargs.get("max_length") + super().__init__(*args, **kwargs) + if self._plaintext_max_length: + self.validators.append(MaxLengthValidator(self._plaintext_max_length)) + + def get_prep_value(self, value): + value = super().get_prep_value(value) + if value is None: + return None + return encrypt_value(value) + + def from_db_value(self, value, expression, connection): + if value is None: + return None + if value == "": + return value + return decrypt_value(value) + + def to_python(self, value): + return super().to_python(value) + + +class EncryptedTextField(EncryptedMixin, models.TextField): + pass + + +class EncryptedCharField(EncryptedMixin, models.TextField): + pass diff --git a/common/middleware/check_login_middleware.py b/common/middleware/check_login_middleware.py index b298df91..718f2964 100644 --- a/common/middleware/check_login_middleware.py +++ b/common/middleware/check_login_middleware.py @@ -12,8 +12,6 @@ "/oidc/callback/", "/oidc/authenticate/", "/oidc/logout/", - "/dingding/callback/", - "/dingding/authenticate/", "/cas/authenticate/", ] diff --git a/common/templates/config.html b/common/templates/config.html index 1ecbdfa6..9e51b0a8 100755 --- a/common/templates/config.html +++ b/common/templates/config.html @@ -804,95 +804,6 @@
Workflow Notifications
-
- -
-
- -
-
-
-
- -
-
- -
-
-
- -
@@ -1578,15 +1489,13 @@
Current Approval Flow: Current Approval Flow: Current Approval Flow: Current Approval Flow: Login To Archery{{ custom_title_suffix }}
{% endif %} - {% if dingding_enabled or oidc_enabled %} + {% if oidc_enabled %} diff --git a/common/test_encryption.py b/common/test_encryption.py new file mode 100644 index 00000000..ba644bb4 --- /dev/null +++ b/common/test_encryption.py @@ -0,0 +1,76 @@ +import os +from unittest.mock import patch + +from django.test import SimpleTestCase, override_settings + +from common.encryption import ( + DecryptionError, + ENCRYPTED_VALUE_PREFIX, + decrypt_value, + encrypt_value, + generate_field_encryption_key, + get_multi_fernet, +) +from common.test_fixtures import LEGACY_MIRAGE_CIPHERTEXTS, LEGACY_MIRAGE_SECRET_KEY +from common.utils.aes_decryptor import Prpcrypt + + +def _encrypt_legacy_mirage(value, secret_key): + assert secret_key == LEGACY_MIRAGE_SECRET_KEY + return LEGACY_MIRAGE_CIPHERTEXTS[value] + + +class EncryptionHelpersTest(SimpleTestCase): + def tearDown(self): + get_multi_fernet.cache_clear() + + @override_settings(FIELD_ENCRYPTION_KEYS=generate_field_encryption_key()) + def test_round_trip_encrypts_with_prefix(self): + get_multi_fernet.cache_clear() + + encrypted = encrypt_value("top-secret") + + self.assertTrue(encrypted.startswith(ENCRYPTED_VALUE_PREFIX)) + self.assertEqual(decrypt_value(encrypted), "top-secret") + + @override_settings(FIELD_ENCRYPTION_KEYS=generate_field_encryption_key()) + def test_prefix_looking_plaintext_is_still_encrypted(self): + get_multi_fernet.cache_clear() + + encrypted = encrypt_value("enc1:not-a-token") + + self.assertNotEqual(encrypted, "enc1:not-a-token") + self.assertEqual(decrypt_value(encrypted), "enc1:not-a-token") + + @override_settings(FIELD_ENCRYPTION_KEYS="") + def test_encrypt_requires_configured_keys(self): + get_multi_fernet.cache_clear() + + with patch.dict(os.environ, {"FIELD_ENCRYPTION_KEYS": ""}, clear=False): + with self.assertRaises(ValueError): + encrypt_value("top-secret") + + @override_settings(FIELD_ENCRYPTION_KEYS=generate_field_encryption_key()) + def test_decrypts_legacy_hex_ciphertext(self): + get_multi_fernet.cache_clear() + + legacy = Prpcrypt().encrypt("legacy-password") + + self.assertEqual(decrypt_value(legacy), "legacy-password") + + @override_settings( + FIELD_ENCRYPTION_KEYS=generate_field_encryption_key(), + SECRET_KEY=LEGACY_MIRAGE_SECRET_KEY, + ) + def test_decrypts_legacy_mirage_ciphertext(self): + get_multi_fernet.cache_clear() + + legacy = _encrypt_legacy_mirage("legacy-user", LEGACY_MIRAGE_SECRET_KEY) + + self.assertEqual(decrypt_value(legacy), "legacy-user") + + def test_decrypt_legacy_value_raises_for_unknown_ciphertext(self): + get_multi_fernet.cache_clear() + + with self.assertRaises(DecryptionError): + decrypt_value("not-a-legacy-ciphertext") diff --git a/common/test_fixtures.py b/common/test_fixtures.py new file mode 100644 index 00000000..e81ff6c8 --- /dev/null +++ b/common/test_fixtures.py @@ -0,0 +1,18 @@ +LEGACY_MIRAGE_SECRET_KEY = "test-mirage-secret-key-1234567890" +LEGACY_MIRAGE_CBC_IV = "fedcba0987654321" + +# These ciphertexts were generated from the pre-migration Mirage AES/ECB format +# with LEGACY_MIRAGE_SECRET_KEY and are pinned here to keep tests deterministic. +LEGACY_MIRAGE_CIPHERTEXTS = { + "legacy-user": "_Z3ltHqLM3ElYkNjsFGCMA==", + "legacy-root": "MVDwR1ka9b_2PNsblc75QQ==", + "legacy-password": "whY6rTtCeZicLTJmkbkXhg==", + "legacy-ak": "lqN38b1I1guLDVHUFmQZ4A==", + "legacy-sk": "xDwJ96BefaSfmGXSH_mThQ==", +} + +# This ciphertext was generated from the pre-migration Mirage AES/CBC format +# with LEGACY_MIRAGE_SECRET_KEY and LEGACY_MIRAGE_CBC_IV. +LEGACY_MIRAGE_CBC_CIPHERTEXTS = { + "legacy-cbc-user": "DvylVMJdhBSwATSUJMZiOg==", +} diff --git a/common/tests.py b/common/tests.py index aa161adb..411f64c6 100644 --- a/common/tests.py +++ b/common/tests.py @@ -16,9 +16,12 @@ SqlWorkflowContent, QueryLog, ResourceGroup, + TwoFactorAuthConfig, ) from common.utils.chart_dao import ChartDao from common.auth import init_user +from common.twofa.sms import SMS +from common.twofa.totp import TOTP from common.utils.extend_json_encoder import ExtendJSONEncoderFTime User = get_user_model() @@ -164,31 +167,6 @@ def tearDown(self): archer_config.set("mail_ssl", "") -class DingTest(TestCase): - def setUp(self): - self.url = "some_url" - self.content = "some_content" - - @patch("requests.post") - def testDing(self, post): - sender = MsgSender() - post.return_value.json.return_value = {"errcode": 0} - with self.assertLogs("default", level="DEBUG") as lg: - sender.send_ding(self.url, self.content) - post.assert_called_once_with( - url=self.url, - json={"msgtype": "text", "text": {"content": self.content}}, - ) - self.assertIn("DingTalk webhook sent successfully", lg.output[0]) - post.return_value.json.return_value = {"errcode": 1, "errmsg": "test_error"} - with self.assertLogs("default", level="ERROR") as lg: - sender.send_ding(self.url, self.content) - self.assertIn("test_error", lg.output[0]) - - def tearDown(self): - pass - - class GlobalInfoTest(TestCase): def setUp(self): self.u1 = User(username="test_user", display="Chinese display", is_active=True) @@ -559,6 +537,35 @@ def test_init_user(self): self.assertEqual(self.u1, self.resource_group1.users_set.get(pk=self.u1.pk)) +class TestTwoFactorAuth(TestCase): + def setUp(self): + self.user = User.objects.create( + username="twofa_user", + display="TwoFA User", + is_active=True, + ) + + def tearDown(self): + TwoFactorAuthConfig.objects.all().delete() + self.user.delete() + + def test_sms_verify_returns_controlled_error_when_config_missing(self): + result = SMS(user=self.user).verify("123456") + + self.assertEqual( + result, + {"status": 1, "msg": "SMS 2FA is not configured for this account."}, + ) + + def test_totp_verify_returns_controlled_error_when_config_missing(self): + result = TOTP(user=self.user).verify("123456") + + self.assertEqual( + result, + {"status": 1, "msg": "TOTP 2FA is not configured for this account."}, + ) + + class PermissionTest(TestCase): def setUp(self) -> None: self.user = User.objects.create( diff --git a/common/twofa/sms.py b/common/twofa/sms.py index 7eb58620..6c89ae25 100644 --- a/common/twofa/sms.py +++ b/common/twofa/sms.py @@ -62,7 +62,20 @@ def verify(self, otp, phone=None): if phone: phone = phone else: - phone = TwoFactorAuthConfig.objects.get(username=self.user.username).phone + try: + phone = TwoFactorAuthConfig.objects.get( + user=self.user, auth_type=self.auth_type + ).phone + except TwoFactorAuthConfig.DoesNotExist: + logger.warning( + "Missing SMS 2FA config for user=%s auth_type=%s", + getattr(self.user, "username", None), + self.auth_type, + ) + return { + "status": 1, + "msg": "SMS 2FA is not configured for this account.", + } r = get_redis_connection("default") data = r.get(f"captcha-{phone}") @@ -86,7 +99,6 @@ def save(self, phone): self.disable(self.auth_type) # Create new 2FA config TwoFactorAuthConfig.objects.create( - username=self.user.username, auth_type=self.auth_type, phone=phone, user=self.user, diff --git a/common/twofa/totp.py b/common/twofa/totp.py index 150ce638..779f4510 100644 --- a/common/twofa/totp.py +++ b/common/twofa/totp.py @@ -24,9 +24,20 @@ def verify(self, otp, key=None): if key: secret_key = key else: - secret_key = TwoFactorAuthConfig.objects.get( - username=self.user.username, auth_type=self.auth_type - ).secret_key + try: + secret_key = TwoFactorAuthConfig.objects.get( + user=self.user, auth_type=self.auth_type + ).secret_key + except TwoFactorAuthConfig.DoesNotExist: + logger.warning( + "Missing TOTP 2FA config for user=%s auth_type=%s", + getattr(self.user, "username", None), + self.auth_type, + ) + return { + "status": 1, + "msg": "TOTP 2FA is not configured for this account.", + } t = pyotp.TOTP(secret_key) status = t.verify(otp) result["status"] = 0 if status else 1 @@ -53,7 +64,6 @@ def save(self, secret_key): self.disable(self.auth_type) # Create new 2FA config TwoFactorAuthConfig.objects.create( - username=self.user.username, auth_type=self.auth_type, secret_key=secret_key, user=self.user, diff --git a/common/utils/ding_api.py b/common/utils/ding_api.py deleted file mode 100644 index 512901af..00000000 --- a/common/utils/ding_api.py +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import logging -import requests -from django.http import JsonResponse -from django_redis import get_redis_connection -from common.config import SysConfig -from common.utils.permission import superuser_required -from sql.models import Users -from sql.utils.tasks import add_sync_ding_user_schedule - -logger = logging.getLogger("default") -rs = get_redis_connection("default") - - -def get_access_token(): - """Get DingTalk access token: https://ding-doc.dingtalk.com/doc#/serverapi2/eev437""" - # Read from cache first - try: - access_token = rs.execute_command(f"get ding_access_token") - except Exception as e: - logger.error(f"Failed to read DingTalk access_token from cache: {e}") - access_token = None - if access_token: - return access_token.decode() - # Request from DingTalk API - sys_config = SysConfig() - app_key = sys_config.get("ding_app_key") - app_secret = sys_config.get("ding_app_secret") - url = f"https://oapi.dingtalk.com/gettoken?appkey={app_key}&appsecret={app_secret}" - resp = requests.get(url, timeout=3).json() - if resp.get("errcode") == 0: - access_token = resp.get("access_token") - expires_in = resp.get("expires_in") - rs.execute_command(f"SETEX ding_access_token {expires_in-60} {access_token}") - return access_token - else: - logger.error(f"Failed to fetch DingTalk access_token: {resp}") - return None - - -def get_ding_user_id(username): - """Update user's ding_user_id.""" - try: - ding_user_id = rs.execute_command("GET {}".format(username.lower())) - if ding_user_id: - user = Users.objects.get(username=username) - if user.ding_user_id != str(ding_user_id, encoding="utf8"): - user.ding_user_id = str(ding_user_id, encoding="utf8") - user.save(update_fields=["ding_user_id"]) - except Exception as e: - logger.error(f"Failed to update user ding_user_id: {e}") - - -def get_dept_list_id_fetch_child(token, parent_dept_id): - """Get all child department IDs recursively.""" - ids = [int(parent_dept_id)] - url = ( - "https://oapi.dingtalk.com/department/list_ids?id={0}&access_token={1}".format( - parent_dept_id, token - ) - ) - resp = requests.get(url, timeout=3).json() - if resp.get("errcode") == 0: - for dept_id in resp.get("sub_dept_id_list"): - ids.extend(get_dept_list_id_fetch_child(token, dept_id)) - return list(set(ids)) - - -def sync_ding_user_id(): - """ - Archery users log in with employee ID (`username`), which maps to DingTalk's - `jobnumber` field. Use `jobnumber` to find and cache user `ding_user_id`. - """ - sys_config = SysConfig() - ding_dept_ids = sys_config.get("ding_dept_ids", "") - username2ding = sys_config.get("ding_archery_username") - token = get_access_token() - if not token: - return False - # Fetch all department IDs - sub_dept_id_list = [] - for dept_id in list(set(ding_dept_ids.split(","))): - sub_dept_id_list.extend(get_dept_list_id_fetch_child(token, dept_id)) - # Iterate users in each department - user_ids = [] - for sdi in sub_dept_id_list: - url = f"https://oapi.dingtalk.com/user/getDeptMember?access_token={token}&deptId={sdi}" - try: - resp = requests.get(url, timeout=3).json() - if resp.get("errcode") == 0: - user_ids.extend(resp.get("userIds")) - else: - raise Exception(f"Failed to fetch department users: {resp}") - except Exception as e: - raise Exception(f"Failed to fetch department users: {e}") - # Fetch user details and cache mappings - for user_id in list(set(user_ids)): - url = ( - f"https://oapi.dingtalk.com/user/get?access_token={token}&userid={user_id}" - ) - try: - resp = requests.get(url, timeout=3).json() - if resp.get("errcode") == 0: - if not resp.get(username2ding): - raise Exception( - f"DingTalk user payload does not include `{username2ding}`. " - f"Please check `ding_archery_username` config: {resp}" - ) - rs.execute_command( - f"SETEX {resp.get(username2ding).lower()} 86400 {resp.get('userid')}" - ) - else: - raise Exception(f"Failed to fetch user info: {resp}") - except Exception as e: - raise Exception(f"Failed to fetch user info: {e}") - return True - - -@superuser_required -def sync_ding_user(request): - """Trigger manual sync and also register daily schedule sync.""" - try: - # Add schedule and trigger sync - add_sync_ding_user_schedule() - return JsonResponse({"status": 0, "msg": "Sync triggered successfully"}) - except Exception as e: - return JsonResponse({"status": 1, "msg": f"Sync trigger failed: {e}"}) diff --git a/common/utils/sendmsg.py b/common/utils/sendmsg.py index 371162ec..84f9164d 100755 --- a/common/utils/sendmsg.py +++ b/common/utils/sendmsg.py @@ -11,7 +11,6 @@ from email.utils import formataddr from common.config import SysConfig -from common.utils.ding_api import get_access_token from common.utils.wx_api import get_wx_access_token from common.utils.feishu_api import * @@ -34,8 +33,6 @@ def __init__(self, **kwargs): self.MAIL_SSL = sys_config.get("mail_ssl") self.MAIL_REVIEW_FROM_ADDR = sys_config.get("mail_smtp_user") self.MAIL_REVIEW_FROM_PASSWORD = sys_config.get("mail_smtp_password") - # DingTalk settings - self.ding_agent_id = sys_config.get("ding_agent_id") # WeCom settings self.wx_agent_id = sys_config.get("wx_agent_id") # Feishu settings @@ -136,54 +133,6 @@ def send_email(self, subject, body, to, **kwargs): logger.error(errmsg) return errmsg - @staticmethod - def send_ding(url, content): - """ - Send DingTalk webhook message. - :param url: - :param content: - :return: - """ - data = { - "msgtype": "text", - "text": {"content": "{}".format(content)}, - } - r = requests.post(url=url, json=data) - r_json = r.json() - if r_json["errcode"] == 0: - logger.debug( - f"DingTalk webhook sent successfully\nTarget:{url}\nContent:{content}" - ) - else: - logger.error( - f"DingTalk webhook failed\nRequest url:{url}\nRequest data:{data}\nResponse:{r_json}" - ) - - def send_ding2user(self, userid_list, content): - """ - Send DingTalk message to specific users. - :param userid_list: - :param content: - :return: - """ - access_token = get_access_token() - send_url = f"https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token={access_token}" - data = { - "userid_list": ",".join(list(set(userid_list))), - "agent_id": self.ding_agent_id, - "msg": {"msgtype": "text", "text": {"content": f"{content}"}}, - } - r = requests.post(url=send_url, json=data, timeout=5) - r_json = r.json() - if r_json["errcode"] == 0: - logger.debug( - f"DingTalk message sent successfully\nTargets:{userid_list}\nContent:{content}" - ) - else: - logger.error( - f"DingTalk message failed\nRequest url:{send_url}\nRequest data:{data}\nResponse:{r_json}" - ) - def send_wx2user(self, msg, user_list): if not user_list: logger.error("WeCom push failed: unable to determine target users.") diff --git a/conftest.py b/conftest.py index bf12eeab..7092a16a 100644 --- a/conftest.py +++ b/conftest.py @@ -1,5 +1,10 @@ +import os import datetime +os.environ.setdefault( + "FIELD_ENCRYPTION_KEYS", "9R_Jxat_be2SV-UbCS0dAYQ0SGjZVf0JyN-VPkVNyi0=" +) + import pytest from pytest_mock import MockFixture from django.contrib.auth.models import Group @@ -167,7 +172,6 @@ def create_resource_group(db): is_deleted=False, qywx_webhook="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx", feishu_webhook="https://open.feishu.cn/open-apis/bot/v2/hook/xxx", - ding_webhook="https://oapi.dingtalk.com/robot/send?access_token=xxx", ) yield resource_group resource_group.delete() diff --git a/mirage/__init__.py b/mirage/__init__.py new file mode 100644 index 00000000..18688ab8 --- /dev/null +++ b/mirage/__init__.py @@ -0,0 +1,3 @@ +from . import fields + +__all__ = ["fields"] diff --git a/mirage/fields.py b/mirage/fields.py new file mode 100644 index 00000000..fa1ab35a --- /dev/null +++ b/mirage/fields.py @@ -0,0 +1,12 @@ +from django.db import models + + +class EncryptedCharField(models.CharField): + pass + + +class EncryptedTextField(models.TextField): + pass + + +__all__ = ["EncryptedCharField", "EncryptedTextField"] diff --git a/requirements.txt b/requirements.txt index 2682be8f..c9ca99bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ aliyun-python-sdk-rds==2.1.1 cx-Oracle==7.3.0 supervisor==4.1.0 phoenixdb==1.2.1 -django-mirage-field==1.4.0 +cryptography>=45.0.0,<46.0.0 schema-sync==0.9.7 parsedatetime==2.4 sshtunnel==0.1.5 @@ -39,7 +39,6 @@ django-environ==0.8.1 alibabacloud_dysmsapi20170525==2.0.9 tencentcloud-sdk-python==3.0.656 mozilla-django-oidc==3.0.0 -django-auth-dingding==0.0.3 django-cas-ng==4.3.0 cassandra-driver httpx diff --git a/sql/admin.py b/sql/admin.py index c6e927ae..85f7028f 100755 --- a/sql/admin.py +++ b/sql/admin.py @@ -61,7 +61,6 @@ class UsersAdmin(UserAdmin): "fields": ( "display", "email", - "ding_user_id", "wx_user_id", "feishu_open_id", ) @@ -91,7 +90,6 @@ class UsersAdmin(UserAdmin): "fields": ( "display", "email", - "ding_user_id", "wx_user_id", "feishu_open_id", ) @@ -118,7 +116,7 @@ class UsersAdmin(UserAdmin): # User 2FA management @admin.register(TwoFactorAuthConfig) class TwoFactorAuthConfigAdmin(admin.ModelAdmin): - list_display = ("id", "username", "auth_type", "phone", "secret_key", "user_id") + list_display = ("id", "user", "auth_type", "phone", "secret_key", "user_id") # Resource group management @@ -127,7 +125,6 @@ class ResourceGroupAdmin(admin.ModelAdmin): list_display = ( "group_id", "group_name", - "ding_webhook", "feishu_webhook", "qywx_webhook", "is_deleted", diff --git a/sql/local_demo.py b/sql/local_demo.py index afa5912a..fb986ebd 100644 --- a/sql/local_demo.py +++ b/sql/local_demo.py @@ -291,7 +291,6 @@ def _seed_resource_groups(log): "group_sort": index, "group_level": 1, "is_deleted": 0, - "ding_webhook": "", "feishu_webhook": "", "qywx_webhook": "", }, diff --git a/sql/management/commands/generate_field_encryption_key.py b/sql/management/commands/generate_field_encryption_key.py new file mode 100644 index 00000000..88f58d14 --- /dev/null +++ b/sql/management/commands/generate_field_encryption_key.py @@ -0,0 +1,10 @@ +from django.core.management.base import BaseCommand + +from common.encryption import generate_field_encryption_key + + +class Command(BaseCommand): + help = "Generate a FIELD_ENCRYPTION_KEYS Fernet key." + + def handle(self, *args, **options): + self.stdout.write(generate_field_encryption_key()) diff --git a/sql/management/commands/reencrypt_sensitive_fields.py b/sql/management/commands/reencrypt_sensitive_fields.py new file mode 100644 index 00000000..69f555b2 --- /dev/null +++ b/sql/management/commands/reencrypt_sensitive_fields.py @@ -0,0 +1,46 @@ +from django.core.management.base import BaseCommand + +from sql.models import ( + CloudAccessKey, + Config, + Instance, + InstanceAccount, + Tunnel, + TwoFactorAuthConfig, +) + +MODEL_FIELDS = ( + (Instance, ("user", "password")), + (Tunnel, ("user", "password", "pkey", "pkey_password")), + (InstanceAccount, ("password",)), + (Config, ("value",)), + (TwoFactorAuthConfig, ("phone", "secret_key")), + (CloudAccessKey, ("key_id", "key_secret")), +) + + +class Command(BaseCommand): + help = "Rewrite sensitive fields into the current encrypted format." + + def add_arguments(self, parser): + parser.add_argument("--batch-size", type=int, default=500) + + def handle(self, *args, **options): + batch_size = options["batch_size"] + total_rows = 0 + + for model, fields in MODEL_FIELDS: + queryset = model.objects.order_by("pk").iterator(chunk_size=batch_size) + model_count = 0 + for obj in queryset: + update_fields = [ + field for field in fields if getattr(obj, field) not in (None, "") + ] + if not update_fields: + continue + obj.save(update_fields=update_fields) + model_count += 1 + total_rows += model_count + self.stdout.write(f"{model.__name__}: rewritten {model_count} rows") + + self.stdout.write(self.style.SUCCESS(f"Rewrote {total_rows} rows in total")) diff --git a/sql/migrations/0003_remove_resourcegroup_ding_webhook_and_more.py b/sql/migrations/0003_remove_resourcegroup_ding_webhook_and_more.py new file mode 100644 index 00000000..03cf87b4 --- /dev/null +++ b/sql/migrations/0003_remove_resourcegroup_ding_webhook_and_more.py @@ -0,0 +1,229 @@ +# Generated by Django 4.1.13 on 2026-04-02 22:13 + +import base64 +import common.fields.encrypted +from Crypto.Cipher import AES +from binascii import a2b_hex +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from django.db import migrations, models +from django.utils.encoding import force_bytes, force_str + + +def _decrypt_mirage(value, secret_key=None, cipher_mode=None, cipher_iv=None): + from django.conf import settings + + key = secret_key or getattr(settings, "MIRAGE_SECRET_KEY", None) + if not key: + return None + cipher_key = base64.urlsafe_b64encode(force_bytes(key))[:32] + if len(cipher_key) not in (16, 24, 32): + return None + cipher_mode = ( + cipher_mode or getattr(settings, "MIRAGE_CIPHER_MODE", "ECB") + ).upper() + iv = force_bytes( + cipher_iv or getattr(settings, "MIRAGE_CIPHER_IV", "1234567890abcdef") + ) + if cipher_mode == "CBC": + mode = modes.CBC(iv) + else: + mode = modes.ECB() + cipher = Cipher(algorithms.AES(cipher_key), mode, default_backend()).decryptor() + unpadder = padding.PKCS7(algorithms.AES(cipher_key).block_size).unpadder() + plaintext = cipher.update(base64.urlsafe_b64decode(force_bytes(value))) + plaintext += cipher.finalize() + return force_str(unpadder.update(plaintext) + unpadder.finalize()) + + +def _decrypt_old_hex(value): + if len(value) < 32 or len(value) % 2 != 0: + return None + + try: + cryptor = AES.new( + b"eCcGFZQj6PNoSSma31LR39rTzTbLkU8E", AES.MODE_CBC, b"0000000000000000" + ) + plain_text = cryptor.decrypt(a2b_hex(value)) + return plain_text.decode().rstrip("\0") + except Exception: + return None + + +def _decrypt_legacy_value(value, secret_key, cipher_mode=None, cipher_iv=None): + if value in ("", None): + return value + + for decryptor in ( + lambda ciphertext: _decrypt_mirage( + ciphertext, secret_key, cipher_mode, cipher_iv + ), + _decrypt_old_hex, + ): + try: + plaintext = decryptor(value) + except Exception: + plaintext = None + if plaintext is not None: + return plaintext + + raise ValueError( + "Unable to decrypt legacy InstanceAccount.user value " + f"marker={value[:16]!r} length={len(value)}" + ) + + +def decrypt_instanceaccount_user(apps, schema_editor): + InstanceAccount = apps.get_model("sql", "InstanceAccount") + from django.conf import settings + + secret_key = getattr(settings, "MIRAGE_SECRET_KEY", None) or settings.SECRET_KEY + cipher_mode = getattr(settings, "MIRAGE_CIPHER_MODE", "ECB") + cipher_iv = getattr(settings, "MIRAGE_CIPHER_IV", "1234567890abcdef") + + for account in InstanceAccount.objects.using(schema_editor.connection.alias).all(): + if account.user in ("", None): + continue + plaintext = _decrypt_legacy_value( + account.user, secret_key, cipher_mode, cipher_iv + ) + InstanceAccount.objects.using(schema_editor.connection.alias).filter( + pk=account.pk + ).update(user=plaintext) + + +def noop_reverse(apps, schema_editor): + return + + +class Migration(migrations.Migration): + + dependencies = [ + ("sql", "0002_permissionrequest_alter_workflowaudit_workflow_type_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="resourcegroup", + name="ding_webhook", + ), + migrations.RemoveField( + model_name="twofactorauthconfig", + name="username", + ), + migrations.RemoveField( + model_name="users", + name="ding_user_id", + ), + migrations.AlterField( + model_name="cloudaccesskey", + name="key_id", + field=common.fields.encrypted.EncryptedCharField(max_length=200), + ), + migrations.AlterField( + model_name="cloudaccesskey", + name="key_secret", + field=common.fields.encrypted.EncryptedCharField(max_length=200), + ), + migrations.AlterField( + model_name="config", + name="value", + field=common.fields.encrypted.EncryptedCharField( + max_length=500, verbose_name="Config Value" + ), + ), + migrations.AlterField( + model_name="instance", + name="password", + field=common.fields.encrypted.EncryptedCharField( + blank=True, default="", max_length=300, verbose_name="Password" + ), + ), + migrations.AlterField( + model_name="instance", + name="user", + field=common.fields.encrypted.EncryptedCharField( + blank=True, default="", max_length=200, verbose_name="Username" + ), + ), + migrations.AlterField( + model_name="instanceaccount", + name="password", + field=common.fields.encrypted.EncryptedCharField( + blank=True, default="", max_length=128, verbose_name="Password" + ), + ), + migrations.RunPython(decrypt_instanceaccount_user, noop_reverse), + migrations.AlterField( + model_name="instanceaccount", + name="user", + field=models.CharField(max_length=128, verbose_name="Account"), + ), + migrations.AlterField( + model_name="tunnel", + name="password", + field=common.fields.encrypted.EncryptedCharField( + blank=True, + default="", + max_length=300, + null=True, + verbose_name="Password", + ), + ), + migrations.AlterField( + model_name="tunnel", + name="pkey", + field=common.fields.encrypted.EncryptedTextField( + blank=True, null=True, verbose_name="Private Key" + ), + ), + migrations.AlterField( + model_name="tunnel", + name="pkey_password", + field=common.fields.encrypted.EncryptedCharField( + blank=True, + default="", + max_length=300, + null=True, + verbose_name="Key Passphrase", + ), + ), + migrations.AlterField( + model_name="tunnel", + name="user", + field=common.fields.encrypted.EncryptedCharField( + blank=True, + default="", + max_length=200, + null=True, + verbose_name="Username", + ), + ), + migrations.AlterField( + model_name="twofactorauthconfig", + name="auth_type", + field=models.CharField( + choices=[ + ("totp", "Google Authenticator"), + ("sms", "SMS Verification Code"), + ], + max_length=128, + verbose_name="Authentication Type", + ), + ), + migrations.AlterField( + model_name="twofactorauthconfig", + name="phone", + field=common.fields.encrypted.EncryptedCharField( + default="", max_length=64, null=True, verbose_name="Phone Number" + ), + ), + migrations.AlterField( + model_name="twofactorauthconfig", + name="secret_key", + field=common.fields.encrypted.EncryptedCharField( + max_length=256, null=True, verbose_name="User Secret" + ), + ), + ] diff --git a/sql/models.py b/sql/models.py index 6965b823..2e9b043e 100755 --- a/sql/models.py +++ b/sql/models.py @@ -5,11 +5,10 @@ from django.db import models from django.contrib.auth.models import AbstractUser -from mirage import fields from django.utils.translation import gettext as _ from django.conf import settings -from mirage.crypto import Crypto +from common.fields import EncryptedCharField, EncryptedTextField from common.utils.const import WorkflowStatus, WorkflowType, WorkflowAction logger = logging.getLogger("default") @@ -38,7 +37,6 @@ class ResourceGroup(models.Model): group_parent_id = models.BigIntegerField("Parent ID", default=0) group_sort = models.IntegerField("Sort Order", default=1) group_level = models.IntegerField("Level", default=1) - ding_webhook = models.CharField("DingTalk webhook URL", max_length=255, blank=True) feishu_webhook = models.CharField("Feishu webhook URL", max_length=255, blank=True) qywx_webhook = models.CharField("WeCom webhook URL", max_length=255, blank=True) is_deleted = models.IntegerField( @@ -63,7 +61,6 @@ class Users(AbstractUser): """ display = models.CharField("Display Name", max_length=50, default="") - ding_user_id = models.CharField("DingTalk User ID", max_length=64, blank=True) wx_user_id = models.CharField("WeCom User ID", max_length=64, blank=True) feishu_open_id = models.CharField("Feishu Open ID", max_length=64, blank=True) failed_login_count = models.IntegerField("Failed Login Count", default=0) @@ -101,14 +98,13 @@ class TwoFactorAuthConfig(models.Model): ("sms", "SMS Verification Code"), ) - username = fields.EncryptedCharField(verbose_name="Username", max_length=200) - auth_type = fields.EncryptedCharField( + auth_type = models.CharField( verbose_name="Authentication Type", max_length=128, choices=auth_type_choice ) - phone = fields.EncryptedCharField( + phone = EncryptedCharField( verbose_name="Phone Number", max_length=64, null=True, default="" ) - secret_key = fields.EncryptedCharField( + secret_key = EncryptedCharField( verbose_name="User Secret", max_length=256, null=True ) user = models.ForeignKey(Users, on_delete=models.CASCADE) @@ -166,17 +162,17 @@ class Tunnel(models.Model): tunnel_name = models.CharField("Tunnel Name", max_length=50, unique=True) host = models.CharField("Tunnel Host", max_length=200) port = models.IntegerField("Port", default=0) - user = fields.EncryptedCharField( + user = EncryptedCharField( verbose_name="Username", max_length=200, default="", blank=True, null=True ) - password = fields.EncryptedCharField( + password = EncryptedCharField( verbose_name="Password", max_length=300, default="", blank=True, null=True ) - pkey = fields.EncryptedTextField(verbose_name="Private Key", blank=True, null=True) + pkey = EncryptedTextField(verbose_name="Private Key", blank=True, null=True) pkey_path = models.FileField( verbose_name="Key File Path", blank=True, null=True, upload_to="keys/" ) - pkey_password = fields.EncryptedCharField( + pkey_password = EncryptedCharField( verbose_name="Key Passphrase", max_length=300, default="", blank=True, null=True ) create_time = models.DateTimeField("Created Time", auto_now_add=True) @@ -219,10 +215,10 @@ class Instance(models.Model, PasswordMixin): ) host = models.CharField("Instance Host", max_length=200) port = models.IntegerField("Port", default=0) - user = fields.EncryptedCharField( + user = EncryptedCharField( verbose_name="Username", max_length=200, default="", blank=True ) - password = fields.EncryptedCharField( + password = EncryptedCharField( verbose_name="Password", max_length=300, default="", blank=True ) is_ssl = models.BooleanField("Enable SSL", default=False) @@ -862,14 +858,14 @@ class InstanceAccount(models.Model): """ instance = models.ForeignKey(Instance, on_delete=models.CASCADE) - user = fields.EncryptedCharField(verbose_name="Account", max_length=128) + user = models.CharField(verbose_name="Account", max_length=128) host = models.CharField( verbose_name="Host", max_length=64 ) # MySQL stores host info here. db_name = models.CharField( verbose_name="Database Name", max_length=128 ) # MongoDB stores database name here. - password = fields.EncryptedCharField( + password = EncryptedCharField( verbose_name="Password", max_length=128, default="", blank=True ) remark = models.CharField("Remark", max_length=255) @@ -1054,7 +1050,7 @@ class Config(models.Model): """ item = models.CharField("Config Item", max_length=100, unique=True) - value = fields.EncryptedCharField(verbose_name="Config Value", max_length=500) + value = EncryptedCharField(verbose_name="Config Value", max_length=500) description = models.CharField( "Description", max_length=200, default="", blank=True ) @@ -1071,28 +1067,19 @@ class CloudAccessKey(models.Model): cloud_type_choices = (("aliyun", "aliyun"),) type = models.CharField(max_length=20, default="", choices=cloud_type_choices) - key_id = models.CharField(max_length=200) - key_secret = models.CharField(max_length=200) + key_id = EncryptedCharField(max_length=200) + key_secret = EncryptedCharField(max_length=200) remark = models.CharField(max_length=50, default="", blank=True) - def __init__(self, *args, **kwargs): - self.c = Crypto() - super().__init__(*args, **kwargs) - @property def raw_key_id(self): """Return key ID in plaintext.""" - return self.c.decrypt(self.key_id) + return self.key_id @property def raw_key_secret(self): """Return key secret in plaintext.""" - return self.c.decrypt(self.key_secret) - - def save(self, *args, **kwargs): - self.key_id = self.c.encrypt(self.key_id) - self.key_secret = self.c.encrypt(self.key_secret) - super(CloudAccessKey, self).save(*args, **kwargs) + return self.key_secret def __str__(self): return f"{self.type}({self.remark})" diff --git a/sql/notify.py b/sql/notify.py index f0714645..cbcebf61 100755 --- a/sql/notify.py +++ b/sql/notify.py @@ -379,38 +379,6 @@ def render(self): self.render_m2sql() -class DingdingWebhookNotifier(LegacyRender): - name = "dingding_webhook" - sys_config_key: str = "ding" - - def send(self): - dingding_webhook = ResourceGroup.objects.get( - group_id=self.audit.group_id - ).ding_webhook - if not dingding_webhook: - return - msg_sender = MsgSender() - for m in self.messages: - msg_sender.send_ding(dingding_webhook, f"{m.msg_title}\n{m.msg_content}") - - -class DingdingPersonNotifier(LegacyRender): - name = "ding_to_person" - sys_config_key: str = "ding_to_person" - - def send(self): - msg_sender = MsgSender() - for m in self.messages: - ding_user_id_list = [ - user.ding_user_id - for user in chain(m.msg_to, m.msg_cc) - if user.ding_user_id - ] - msg_sender.send_ding2user( - ding_user_id_list, f"{m.msg_title}\n{m.msg_content}" - ) - - class FeishuWebhookNotifier(LegacyRender): name = "feishu_webhook" sys_config_key: str = "feishu_webhook" diff --git a/sql/resource_group.py b/sql/resource_group.py index 7891b09b..9bda3ad6 100644 --- a/sql/resource_group.py +++ b/sql/resource_group.py @@ -28,9 +28,7 @@ def group(request): # Filter search conditions. group_obj = ResourceGroup.objects.filter(group_name__icontains=search, is_deleted=0) group_count = group_obj.count() - group_list = group_obj[offset:limit].values( - "group_id", "group_name", "ding_webhook" - ) + group_list = group_obj[offset:limit].values("group_id", "group_name") # Serialize QuerySet. rows = [row for row in group_list] diff --git a/sql/test_notify.py b/sql/test_notify.py index a0138f3a..756f15b1 100644 --- a/sql/test_notify.py +++ b/sql/test_notify.py @@ -25,8 +25,6 @@ LegacyRender, GenericWebhookNotifier, My2SqlResult, - DingdingWebhookNotifier, - DingdingPersonNotifier, FeishuPersonNotifier, FeishuWebhookNotifier, QywxWebhookNotifier, @@ -144,7 +142,7 @@ def setUp(self): remark="Test query note", ) - self.rs = ResourceGroup.objects.create(group_id=1, ding_webhook="url") + self.rs = ResourceGroup.objects.create(group_id=1) self.archive_apply = ArchiveConfig.objects.create( title="Test archive", @@ -505,8 +503,6 @@ def test_general_webhook(self): @pytest.mark.parametrize( "notifier_to_test,method_assert_called", [ - (DingdingWebhookNotifier, "send_ding"), - (DingdingPersonNotifier, "send_ding2user"), (FeishuWebhookNotifier, "send_feishu_webhook"), (FeishuPersonNotifier, "send_feishu_user"), (QywxWebhookNotifier, "send_qywx_webhook"), diff --git a/sql/test_reencrypt_sensitive_fields.py b/sql/test_reencrypt_sensitive_fields.py new file mode 100644 index 00000000..0d0338c4 --- /dev/null +++ b/sql/test_reencrypt_sensitive_fields.py @@ -0,0 +1,116 @@ +import importlib + +from django.core.management import call_command +from django.db import connection +from django.test import SimpleTestCase, TestCase, override_settings + +from common.encryption import ( + ENCRYPTED_VALUE_PREFIX, + generate_field_encryption_key, + get_multi_fernet, +) +from common.test_fixtures import ( + LEGACY_MIRAGE_CBC_CIPHERTEXTS, + LEGACY_MIRAGE_CBC_IV, + LEGACY_MIRAGE_CIPHERTEXTS, + LEGACY_MIRAGE_SECRET_KEY, +) +from sql.models import CloudAccessKey, Instance + + +def _encrypt_legacy_mirage(value, secret_key): + # Pinned ciphertexts captured from the legacy Mirage wire format. + assert secret_key == LEGACY_MIRAGE_SECRET_KEY + return LEGACY_MIRAGE_CIPHERTEXTS[value] + + +@override_settings( + FIELD_ENCRYPTION_KEYS=generate_field_encryption_key(), + SECRET_KEY=LEGACY_MIRAGE_SECRET_KEY, +) +class ReencryptSensitiveFieldsCommandTest(TestCase): + def setUp(self): + get_multi_fernet.cache_clear() + self.instance = Instance.objects.create( + instance_name="legacy-instance", + type="master", + db_type="mysql", + host="127.0.0.1", + port=3306, + user="root", + password="plain-password", + ) + self.access_key = CloudAccessKey.objects.create( + type="aliyun", + key_id="plain-ak", + key_secret="plain-sk", + ) + + legacy_user = _encrypt_legacy_mirage("legacy-root", LEGACY_MIRAGE_SECRET_KEY) + legacy_password = _encrypt_legacy_mirage( + "legacy-password", LEGACY_MIRAGE_SECRET_KEY + ) + legacy_key_id = _encrypt_legacy_mirage("legacy-ak", LEGACY_MIRAGE_SECRET_KEY) + legacy_key_secret = _encrypt_legacy_mirage( + "legacy-sk", LEGACY_MIRAGE_SECRET_KEY + ) + + with connection.cursor() as cursor: + cursor.execute( + "UPDATE sql_instance SET user=%s, password=%s WHERE id=%s", + [legacy_user, legacy_password, self.instance.id], + ) + cursor.execute( + "UPDATE cloud_access_key SET key_id=%s, key_secret=%s WHERE id=%s", + [legacy_key_id, legacy_key_secret, self.access_key.id], + ) + + def tearDown(self): + get_multi_fernet.cache_clear() + + def test_command_rewrites_legacy_values_to_new_prefix(self): + call_command("reencrypt_sensitive_fields", batch_size=10) + + with connection.cursor() as cursor: + cursor.execute( + "SELECT user, password FROM sql_instance WHERE id=%s", + [self.instance.id], + ) + raw_user, raw_password = cursor.fetchone() + cursor.execute( + "SELECT key_id, key_secret FROM cloud_access_key WHERE id=%s", + [self.access_key.id], + ) + raw_key_id, raw_key_secret = cursor.fetchone() + + self.assertTrue(raw_user.startswith(ENCRYPTED_VALUE_PREFIX)) + self.assertTrue(raw_password.startswith(ENCRYPTED_VALUE_PREFIX)) + self.assertTrue(raw_key_id.startswith(ENCRYPTED_VALUE_PREFIX)) + self.assertTrue(raw_key_secret.startswith(ENCRYPTED_VALUE_PREFIX)) + + self.instance.refresh_from_db() + self.access_key.refresh_from_db() + + self.assertEqual(self.instance.user, "legacy-root") + self.assertEqual(self.instance.password, "legacy-password") + self.assertEqual(self.access_key.key_id, "legacy-ak") + self.assertEqual(self.access_key.key_secret, "legacy-sk") + + +MIGRATION_0003 = importlib.import_module( + "sql.migrations.0003_remove_resourcegroup_ding_webhook_and_more" +) + + +class Migration0003MirageDecryptTest(SimpleTestCase): + @override_settings( + MIRAGE_SECRET_KEY=LEGACY_MIRAGE_SECRET_KEY, + MIRAGE_CIPHER_MODE="CBC", + MIRAGE_CIPHER_IV=LEGACY_MIRAGE_CBC_IV, + ) + def test_decrypt_mirage_supports_configured_cbc_mode(self): + plaintext = MIGRATION_0003._decrypt_mirage( + LEGACY_MIRAGE_CBC_CIPHERTEXTS["legacy-cbc-user"] + ) + + self.assertEqual(plaintext, "legacy-cbc-user") diff --git a/sql/urls.py b/sql/urls.py index 66142fd6..20200643 100644 --- a/sql/urls.py +++ b/sql/urls.py @@ -26,7 +26,6 @@ offlinedownload, ) from sql.utils import tasks -from common.utils import ding_api urlpatterns = [ path("", views.index), @@ -163,7 +162,6 @@ path("archive/switch/", archiver.archive_switch), path("archive/once/", archiver.archive_once), path("archive/log/", archiver.archive_log), - path("4admin/sync_ding_user/", ding_api.sync_ding_user), path("audit/log/", audit_log.audit_log), path("audit/input/", audit_log.audit_input), path("user/list/", user.lists), diff --git a/sql/utils/tasks.py b/sql/utils/tasks.py index 6e2fb9c6..4fdceb13 100644 --- a/sql/utils/tasks.py +++ b/sql/utils/tasks.py @@ -41,18 +41,6 @@ def add_kill_conn_schedule(name, run_date, instance_id, thread_id): ) -def add_sync_ding_user_schedule(): - """Add a scheduled task to sync DingTalk user IDs.""" - del_schedule(name="Sync DingTalk User IDs") - schedule( - "common.utils.ding_api.sync_ding_user_id", - name="Sync DingTalk User IDs", - schedule_type="D", - repeats=-1, - timeout=-1, - ) - - def del_schedule(name): """Delete a schedule.""" try: diff --git a/sql/views.py b/sql/views.py index 8332c203..b73ba14c 100644 --- a/sql/views.py +++ b/sql/views.py @@ -64,7 +64,6 @@ def login(request): context={ "sign_up_enabled": SysConfig().get("sign_up_enabled"), "oidc_enabled": settings.ENABLE_OIDC, - "dingding_enabled": settings.ENABLE_DINGDING, "cas_enabled": settings.ENABLE_CAS, "oidc_btn_name": SysConfig().get("oidc_btn_name", "Log in with OIDC"), }, @@ -79,7 +78,7 @@ def twofa(request): username = request.session.get("user") if username: verify_mode = request.session.get("verify_mode") - twofa_enabled = TwoFactorAuthConfig.objects.filter(username=username) + twofa_enabled = TwoFactorAuthConfig.objects.filter(user__username=username) user_auth_types = [twofa.auth_type for twofa in twofa_enabled] auth_types = [] @@ -93,7 +92,7 @@ def twofa(request): auth_types.append(auth_type) if "sms" in user_auth_types: phone = TwoFactorAuthConfig.objects.get( - username=username, auth_type="sms" + user__username=username, auth_type="sms" ).phone else: phone = 0 diff --git a/sql_api/serializers.py b/sql_api/serializers.py index 556a6c2c..85e8f4b7 100644 --- a/sql_api/serializers.py +++ b/sql_api/serializers.py @@ -784,6 +784,10 @@ class CloudAccessKeySerializer(serializers.ModelSerializer): class Meta: model = CloudAccessKey fields = "__all__" + extra_kwargs = { + "key_id": {"write_only": True}, + "key_secret": {"write_only": True}, + } class AliyunRdsSerializer(serializers.ModelSerializer): diff --git a/sql_api/tests.py b/sql_api/tests.py index b70dd463..f7c524dd 100644 --- a/sql_api/tests.py +++ b/sql_api/tests.py @@ -663,7 +663,6 @@ def tearDown(self): def test_token_requires_2fa_when_user_has_totp(self): secret = pyotp.random_base32(32) TwoFactorAuthConfig.objects.create( - username=self.user.username, auth_type="totp", secret_key=secret, user=self.user, @@ -680,7 +679,6 @@ def test_token_requires_2fa_when_user_has_totp(self): def test_token_totp_success(self): secret = pyotp.random_base32(32) TwoFactorAuthConfig.objects.create( - username=self.user.username, auth_type="totp", secret_key=secret, user=self.user, @@ -784,7 +782,6 @@ def test_request_sms_login_otp_success( self, mock_get_authenticator, mock_get_redis ): TwoFactorAuthConfig.objects.create( - username=self.user.username, auth_type="sms", phone="13800138000", user=self.user, diff --git a/sql_api/urls.py b/sql_api/urls.py index 3115965f..957a3381 100644 --- a/sql_api/urls.py +++ b/sql_api/urls.py @@ -176,5 +176,4 @@ ), path("info", views.info), path("debug", views.debug), - path("do_once/mirage", views.mirage), ] diff --git a/sql_api/views.py b/sql_api/views.py index 80c956d6..0cb63325 100644 --- a/sql_api/views.py +++ b/sql_api/views.py @@ -14,11 +14,8 @@ from django_q.brokers import get_broker from django.utils import timezone -from common.utils.aes_decryptor import Prpcrypt from common.utils.permission import superuser_required import archery -from sql.models import Instance -from mirage.tools import Migrator def info(request): @@ -177,7 +174,6 @@ def debug(request): # Mask sensitive information. secret_keys = [ "inception_remote_backup_password", - "ding_app_secret", "feishu_app_secret", "mail_smtp_password", "go_inception_password", @@ -206,27 +202,3 @@ def debug(request): "packages": installed_packages_list, } return JsonResponse(system_info) - - -@superuser_required -def mirage(request): - """Migrate encrypted Instance data. Remove after enough versions.""" - try: - pc = Prpcrypt() - mg_user = Migrator(app="sql", model="Instance", field="user") - mg_password = Migrator(app="sql", model="Instance", field="password") - # Restore password first. - for ins in Instance.objects.all(): - # Ignore records that cannot be decrypted (already invalid data). - try: - Instance(pk=ins.pk, password=pc.decrypt(ins.password)).save( - update_fields=["password"] - ) - except: - pass - # Re-encrypt with django-mirage-field. - mg_user.encrypt() - mg_password.encrypt() - return JsonResponse({"msg": "ok"}) - except Exception as msg: - return JsonResponse({"msg": f"{msg}"}) diff --git a/src/docker-compose/.env b/src/docker-compose/.env index ae4910dc..f8db9829 100644 --- a/src/docker-compose/.env +++ b/src/docker-compose/.env @@ -3,6 +3,7 @@ NGINX_PORT=9123 # https://django-environ.readthedocs.io/en/latest/quickstart.html#usage # https://docs.djangoproject.com/zh-hans/4.1/ref/settings/ DEBUG=false +FIELD_ENCRYPTION_KEYS=9R_Jxat_be2SV-UbCS0dAYQ0SGjZVf0JyN-VPkVNyi0= DATABASE_URL=mysql://root:123456@mysql:3306/archery CACHE_URL=redis://redis:6379/0?PASSWORD=123456 @@ -21,4 +22,3 @@ Q_CLUISTER_SYNC=false # 在网站标题及登录页面追加此内容, 可用于多archery实例的区分。Archery后台也有相同配置,如都做了配置,以后台配置为准 CUSTOM_TITLE_SUFFIX="" - diff --git a/src/docker-compose/.env.local-arm b/src/docker-compose/.env.local-arm index 786420de..93a5aa4e 100644 --- a/src/docker-compose/.env.local-arm +++ b/src/docker-compose/.env.local-arm @@ -2,6 +2,7 @@ NGINX_PORT=9123 DEBUG=false SECRET_KEY=datamingle-local-secret-key-change-me +FIELD_ENCRYPTION_KEYS=9R_Jxat_be2SV-UbCS0dAYQ0SGjZVf0JyN-VPkVNyi0= DATABASE_URL=mysql://root:123456@mysql:3306/archery CACHE_URL=redis://redis:6379/0?PASSWORD=123456 CSRF_TRUSTED_ORIGINS=http://127.0.0.1:9123,http://localhost:9123