Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 6 additions & 24 deletions archery/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -72,8 +68,6 @@
ENABLED_NOTIFIERS=(
list,
[
"sql.notify:DingdingWebhookNotifier",
"sql.notify:DingdingPersonNotifier",
"sql.notify:FeishuWebhookNotifier",
"sql.notify:FeishuPersonNotifier",
"sql.notify:QywxWebhookNotifier",
Expand All @@ -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!
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -421,7 +404,6 @@

SUPPORTED_AUTHENTICATION = [
("LDAP", ENABLE_LDAP),
("DINGDING", ENABLE_DINGDING),
("OIDC", ENABLE_OIDC),
("CAS", ENABLE_CAS),
]
Expand All @@ -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:
Expand Down
5 changes: 0 additions & 5 deletions archery/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 0 additions & 13 deletions common/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"))
10 changes: 0 additions & 10 deletions common/authenticate/dingding_auth.py

This file was deleted.

131 changes: 131 additions & 0 deletions common/encryption.py
Original file line number Diff line number Diff line change
@@ -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)}"
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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()
Comment thread
jruszo marked this conversation as resolved.
Dismissed
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
3 changes: 3 additions & 0 deletions common/fields/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from common.fields.encrypted import EncryptedCharField, EncryptedTextField

__all__ = ["EncryptedCharField", "EncryptedTextField"]
36 changes: 36 additions & 0 deletions common/fields/encrypted.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 0 additions & 2 deletions common/middleware/check_login_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
"/oidc/callback/",
"/oidc/authenticate/",
"/oidc/logout/",
"/dingding/callback/",
"/dingding/authenticate/",
"/cas/authenticate/",
]

Expand Down
Loading
Loading