Compare commits

..

11 Commits

Author SHA1 Message Date
jiangweidong
396144e3a8 perf: Allow admins to configure available MFA services for user auth 2025-12-05 10:50:45 +08:00
老广
c191d86f43 Refactor GitHub Actions workflow for event handling 2025-11-27 14:27:27 +08:00
wangruidong
7911137ffb fix: Truncate asset URL to 128 characters to prevent exceeding length limit 2025-11-27 14:17:19 +08:00
wangruidong
1053933cae fix: Add migration to refresh PostgreSQL collation version 2025-11-27 14:16:44 +08:00
wangruidong
96fdc025cd fix: Search for risk_level, search result is empty 2025-11-26 18:07:20 +08:00
wangruidong
fde19764e0 fix: Processing redirection url unquote 2025-11-25 14:00:31 +08:00
wangruidong
978fbc70e6 perf: Improve city retrieval fallback to handle missing values 2025-11-25 13:59:48 +08:00
Ewall555
636ffd786d feat: add namespace setting to k8s protocol configuration 2025-11-25 11:08:23 +08:00
feng
3b756aa26f perf: Component i18n lang lower 2025-11-25 10:56:37 +08:00
Bai
817c0099d1 perf: client pkg rename 2025-11-21 18:45:49 +08:00
Bai
a0d7871130 perf: client pkg rename 2025-11-21 18:45:49 +08:00
23 changed files with 160 additions and 52 deletions

View File

@@ -1,10 +1,33 @@
on: [push, pull_request, release]
on:
push:
pull_request:
types: [opened, synchronize, closed]
release:
types: [created]
name: JumpServer repos generic handler
jobs:
generic_handler:
name: Run generic handler
handle_pull_request:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: jumpserver/action-generic-handler@master
env:
GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }}
I18N_TOKEN: ${{ secrets.I18N_TOKEN }}
handle_push:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: jumpserver/action-generic-handler@master
env:
GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }}
I18N_TOKEN: ${{ secrets.I18N_TOKEN }}
handle_release:
if: github.event_name == 'release'
runs-on: ubuntu-latest
steps:
- uses: jumpserver/action-generic-handler@master

View File

@@ -268,6 +268,14 @@ class Protocol(ChoicesMixin, models.TextChoices):
'port_from_addr': True,
'required': True,
'secret_types': ['token'],
'setting': {
'namespace': {
'type': 'str',
'required': False,
'default': '',
'label': _('Namespace')
}
}
},
cls.http: {
'port': 80,

View File

@@ -50,7 +50,7 @@ class UserLoginForm(forms.Form):
class UserCheckOtpCodeForm(forms.Form):
code = forms.CharField(label=_('MFA Code'), max_length=128, required=False)
mfa_type = forms.CharField(label=_('MFA type'), max_length=128)
mfa_type = forms.CharField(label=_('MFA type'), max_length=128, required=False)
class CustomCaptchaTextInput(CaptchaTextInput):

View File

@@ -72,10 +72,9 @@ class BaseMFA(abc.ABC):
def is_active(self):
return False
@staticmethod
@abc.abstractmethod
def global_enabled():
return False
@classmethod
def global_enabled(cls):
return cls.name in settings.SECURITY_MFA_ENABLED_BACKENDS
@abc.abstractmethod
def get_enable_url(self) -> str:

View File

@@ -39,9 +39,9 @@ class MFACustom(BaseMFA):
def is_active(self):
return True
@staticmethod
def global_enabled():
return settings.MFA_CUSTOM and callable(mfa_custom_method)
@classmethod
def global_enabled(cls):
return super().global_enabled() and settings.MFA_CUSTOM and callable(mfa_custom_method)
def get_enable_url(self) -> str:
return ''

View File

@@ -50,9 +50,9 @@ class MFAEmail(BaseMFA):
)
sender_util.gen_and_send_async()
@staticmethod
def global_enabled():
return settings.SECURITY_MFA_BY_EMAIL
@classmethod
def global_enabled(cls):
return super().global_enabled and settings.SECURITY_MFA_BY_EMAIL
def disable(self):
return '/ui/#/profile/index'

View File

@@ -29,9 +29,10 @@ class MFAFace(BaseMFA, AuthFaceMixin):
return True
return bool(self.user.face_vector)
@staticmethod
def global_enabled():
@classmethod
def global_enabled(cls):
return (
super().global_enabled() and
settings.XPACK_LICENSE_IS_VALID and
settings.XPACK_LICENSE_EDITION_ULTIMATE and
settings.FACE_RECOGNITION_ENABLED

View File

@@ -25,10 +25,6 @@ class MFAOtp(BaseMFA):
return True
return self.user.otp_secret_key
@staticmethod
def global_enabled():
return True
def get_enable_url(self) -> str:
return reverse('authentication:user-otp-enable-start')

View File

@@ -23,9 +23,9 @@ class MFAPasskey(BaseMFA):
return False
return self.user.passkey_set.count()
@staticmethod
def global_enabled():
return settings.AUTH_PASSKEY
@classmethod
def global_enabled(cls):
return super().global_enabled() and settings.AUTH_PASSKEY
def get_enable_url(self) -> str:
return '/ui/#/profile/passkeys'

View File

@@ -27,9 +27,9 @@ class MFARadius(BaseMFA):
def is_active(self):
return True
@staticmethod
def global_enabled():
return settings.OTP_IN_RADIUS
@classmethod
def global_enabled(cls):
return super().global_enabled() and settings.OTP_IN_RADIUS
def get_enable_url(self) -> str:
return ''

View File

@@ -46,9 +46,9 @@ class MFASms(BaseMFA):
def send_challenge(self):
self.sms.gen_and_send_async()
@staticmethod
def global_enabled():
return settings.SMS_ENABLED
@classmethod
def global_enabled(cls):
return super().global_enabled() and settings.SMS_ENABLED
def get_enable_url(self) -> str:
return '/ui/#/profile/index'

View File

@@ -376,7 +376,7 @@
</div>
{% if form.challenge %}
{% bootstrap_field form.challenge show_label=False %}
{% elif form.mfa_type %}
{% elif form.mfa_type and mfa_backends %}
<div class="form-group" style="display: flex">
{% include '_mfa_login_field.html' %}
</div>

View File

@@ -2,6 +2,7 @@
#
import datetime
import inspect
import sys
if sys.version_info.major == 3 and sys.version_info.minor >= 10:
@@ -334,6 +335,10 @@ class ES(object):
def is_keyword(props: dict, field: str) -> bool:
return props.get(field, {}).get("type", "keyword") == "keyword"
@staticmethod
def is_long(props: dict, field: str) -> bool:
return props.get(field, {}).get("type") == "long"
def get_query_body(self, **kwargs):
new_kwargs = {}
for k, v in kwargs.items():
@@ -361,10 +366,10 @@ class ES(object):
if index_in_field in kwargs:
index['values'] = kwargs[index_in_field]
mapping = self.es.indices.get_mapping(index=self.query_index)
mapping = self.es.indices.get_mapping(index=self.index)
props = (
mapping
.get(self.query_index, {})
.get(self.index, {})
.get('mappings', {})
.get('properties', {})
)
@@ -375,6 +380,9 @@ class ES(object):
if k in ("org_id", "session") and self.is_keyword(props, k):
exact[k] = v
elif self.is_long(props, k):
exact[k] = v
elif k in common_keyword_able:
exact[f"{k}.keyword"] = v

View File

@@ -101,7 +101,7 @@ def get_ip_city(ip):
info = get_ip_city_by_ipip(ip)
if info:
city = info.get('city', _("Unknown"))
city = info.get('city') or _("Unknown")
country = info.get('country')
# 国内城市 并且 语言是中文就使用国内

View File

@@ -569,7 +569,7 @@ class Config(dict):
'SAFE_MODE': False,
'SECURITY_MFA_AUTH': 0, # 0 不开启 1 全局开启 2 管理员开启
'SECURITY_MFA_AUTH_ENABLED_FOR_THIRD_PARTY': True,
'SECURITY_MFA_BY_EMAIL': False,
'SECURITY_MFA_ENABLED_BACKENDS': [],
'SECURITY_COMMAND_EXECUTION': False,
'SECURITY_COMMAND_BLACKLIST': [
'reboot', 'shutdown', 'poweroff', 'halt', 'dd', 'half', 'top'

View File

@@ -43,6 +43,7 @@ class ComponentI18nApi(RetrieveAPIView):
if not lang:
return Response(data)
lang = lang.lower()
if lang not in dict(Language.get_code_mapper()).keys():
lang = 'en'

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.1.13 on 2025-11-27 02:54
from django.db import migrations, connections
def refresh_pg_collation(apps, schema_editor):
for alias, conn in connections.databases.items():
if connections[alias].vendor == "postgresql":
dbname = connections[alias].settings_dict["NAME"]
connections[alias].cursor().execute(
f'ALTER DATABASE "{dbname}" REFRESH COLLATION VERSION;'
)
print(f"Refreshed postgresql collation version for database: {dbname} successfully.")
class Migration(migrations.Migration):
dependencies = [
('settings', '0002_leakpasswords'),
]
operations = [
migrations.RunPython(refresh_pg_collation, migrations.RunPython.noop),
]

View File

@@ -1,3 +1,7 @@
import importlib
import os
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
@@ -117,6 +121,35 @@ class SecurityLoginLimitSerializer(serializers.Serializer):
)
class DynamicMFAChoiceField(serializers.MultipleChoiceField):
def __init__(self, **kwargs):
_choices = self._get_dynamic_choices()
super().__init__(choices=_choices, **kwargs)
@staticmethod
def _get_dynamic_choices():
choices = []
mfa_dir = os.path.join(settings.APPS_DIR, 'authentication', 'mfa')
for filename in os.listdir(mfa_dir):
if not filename.endswith('.py') or filename.startswith('__init__'):
continue
module_name = f'authentication.mfa.{filename[:-3]}'
try:
module = importlib.import_module(module_name)
except ImportError:
continue
for attr_name in dir(module):
item = getattr(module, attr_name)
if not isinstance(item, type) or not attr_name.startswith('MFA'):
continue
if 'BaseMFA' != item.__base__.__name__:
continue
choices.append((item.name, item.display_name))
return choices
class SecurityAuthSerializer(serializers.Serializer):
SECURITY_MFA_AUTH = serializers.ChoiceField(
choices=(
@@ -130,10 +163,10 @@ class SecurityAuthSerializer(serializers.Serializer):
required=False, default=True,
label=_('Third-party login MFA'),
)
SECURITY_MFA_BY_EMAIL = serializers.BooleanField(
required=False, default=False,
label=_('MFA via Email'),
help_text=_('Email as a method for multi-factor authentication')
SECURITY_MFA_ENABLED_BACKENDS = DynamicMFAChoiceField(
default=[], allow_empty=True,
label=_('MFA Backends'),
help_text=_('MFA methods supported for user login')
)
OTP_ISSUER_NAME = serializers.CharField(
required=False, max_length=16, label=_('OTP issuer name'),

View File

@@ -22,13 +22,13 @@ p {
{% trans 'JumpServerClient, currently used to launch the client' %}
</p>
<ul>
<li> <a href="/download/public/JumpServerClient_{{ CLIENT_VERSION }}_x64_en-US.msi">JumpServerClient-x64_en-US.msi</a></li>
<li> <a href="/download/public/JumpServerClient_{{ CLIENT_VERSION }}_x64-setup.exe">JumpServerClient-x64-setup.exe</a></li>
<li> <a href="/download/public/JumpServerClient_{{ CLIENT_VERSION }}_aarch64.dmg">JumpServerClient-aarch64.dmg</a></li>
<li> <a href="/download/public/JumpServerClient_{{ CLIENT_VERSION }}_x64.dmg">JumpServerClient-x64.dmg</a></li>
<li> <a href="/download/public/JumpServerClient_{{ CLIENT_VERSION }}_amd64.AppImage">JumpServerClient-amd64.AppImage</a></li>
<li> <a href="/download/public/JumpServerClient_{{ CLIENT_VERSION }}_amd64.deb">JumpServerClient-amd64.deb</a></li>
<li> <a href="/download/public/JumpServerClient-{{ CLIENT_VERSION }}-1.x86_64.rpm">JumpServerClient-1.x86_64.rpm</a></li>
<li> <a href="/download/public/JumpServerClient_{{ CLIENT_VERSION }}_x64_en-US.msi">JumpServerClient_{{ CLIENT_VERSION }}_x64_en-US.msi</a></li>
<li> <a href="/download/public/JumpServerClient_{{ CLIENT_VERSION }}_x64-setup.exe">JumpServerClient_{{ CLIENT_VERSION }}_x64-setup.exe</a></li>
<li> <a href="/download/public/JumpServerClient_{{ CLIENT_VERSION }}_aarch64.dmg">JumpServerClient_{{ CLIENT_VERSION }}_aarch64.dmg</a></li>
<li> <a href="/download/public/JumpServerClient_{{ CLIENT_VERSION }}_x64.dmg">JumpServerClient_{{ CLIENT_VERSION }}_x64.dmg</a></li>
<li> <a href="/download/public/JumpServerClient_{{ CLIENT_VERSION }}_amd64.AppImage">JumpServerClient_{{ CLIENT_VERSION }}_amd64.AppImage</a></li>
<li> <a href="/download/public/JumpServerClient_{{ CLIENT_VERSION }}_amd64.deb">JumpServerClient_{{ CLIENT_VERSION }}_amd64.deb</a></li>
<li> <a href="/download/public/JumpServerClient-{{ CLIENT_VERSION }}-1.x86_64.rpm">JumpServerClient-{{ CLIENT_VERSION }}-1.x86_64.rpm</a></li>
</ul>
</div>

View File

@@ -1,12 +1,11 @@
# -*- coding: utf-8 -*-
#
import pytz
from datetime import datetime
from common.utils import get_logger
from common.plugins.es import ES
import pytz
from common.plugins.es import ES
from common.utils import get_logger
logger = get_logger(__file__)
@@ -27,8 +26,8 @@ class CommandStore(ES):
"type": "long"
}
}
exact_fields = {}
fuzzy_fields = {'input', 'risk_level', 'user', 'asset', 'account'}
exact_fields = {'risk_level'}
fuzzy_fields = {'input', 'user', 'asset', 'account'}
match_fields = {'input'}
keyword_fields = {'session', 'org_id'}

View File

@@ -160,7 +160,8 @@ class SessionSerializer(BulkOrgResourceModelSerializer):
)
validated_data['user'] = str(user)
validated_data['asset'] = str(asset)
# web 资产 url 太长,超出限制
validated_data['asset'] = str(asset)[:128]
return super().create(validated_data)
def update(self, instance, validated_data):

View File

@@ -6,6 +6,7 @@ import os
import re
import time
from contextlib import contextmanager
from urllib.parse import unquote
import pyotp
from django.conf import settings
@@ -60,6 +61,8 @@ def redirect_user_first_login_or_index(request, redirect_field_name):
# 防止 next 地址为 None
if not url or url.lower() in ['none']:
url = reverse('index')
# 处理下载地址编码问题 '%2Fui%2F'
url = unquote(url)
return url

View File

@@ -37,12 +37,25 @@ logger = get_logger(__name__)
class UserOtpEnableStartView(AuthMixin, TemplateView):
template_name = 'users/user_otp_check_password.html'
@staticmethod
def get_redirect_url():
message_data = {
'title': _('Redirecting'),
'message': _('No MFA services are available. Please contact the administrator'),
'redirect_url': reverse('authentication:login'),
'auto_redirect': True,
}
return FlashMessageUtil.gen_message_url(message_data)
def get(self, request, *args, **kwargs):
try:
self.get_user_from_session()
except SessionEmptyError:
url = reverse('authentication:login') + '?_=otp_enable_start'
return redirect(url)
if not MFAOtp.global_enabled():
return redirect(self.get_redirect_url())
return super().get(request, *args, **kwargs)