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 name: JumpServer repos generic handler
jobs: jobs:
generic_handler: handle_pull_request:
name: Run generic handler 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 runs-on: ubuntu-latest
steps: steps:
- uses: jumpserver/action-generic-handler@master - uses: jumpserver/action-generic-handler@master

View File

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

View File

@@ -50,7 +50,7 @@ class UserLoginForm(forms.Form):
class UserCheckOtpCodeForm(forms.Form): class UserCheckOtpCodeForm(forms.Form):
code = forms.CharField(label=_('MFA Code'), max_length=128, required=False) 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): class CustomCaptchaTextInput(CaptchaTextInput):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ class ComponentI18nApi(RetrieveAPIView):
if not lang: if not lang:
return Response(data) return Response(data)
lang = lang.lower()
if lang not in dict(Language.get_code_mapper()).keys(): if lang not in dict(Language.get_code_mapper()).keys():
lang = 'en' 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 django.utils.translation import gettext_lazy as _
from rest_framework import serializers 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): class SecurityAuthSerializer(serializers.Serializer):
SECURITY_MFA_AUTH = serializers.ChoiceField( SECURITY_MFA_AUTH = serializers.ChoiceField(
choices=( choices=(
@@ -130,10 +163,10 @@ class SecurityAuthSerializer(serializers.Serializer):
required=False, default=True, required=False, default=True,
label=_('Third-party login MFA'), label=_('Third-party login MFA'),
) )
SECURITY_MFA_BY_EMAIL = serializers.BooleanField( SECURITY_MFA_ENABLED_BACKENDS = DynamicMFAChoiceField(
required=False, default=False, default=[], allow_empty=True,
label=_('MFA via Email'), label=_('MFA Backends'),
help_text=_('Email as a method for multi-factor authentication') help_text=_('MFA methods supported for user login')
) )
OTP_ISSUER_NAME = serializers.CharField( OTP_ISSUER_NAME = serializers.CharField(
required=False, max_length=16, label=_('OTP issuer name'), required=False, max_length=16, label=_('OTP issuer name'),

View File

@@ -22,13 +22,13 @@ p {
{% trans 'JumpServerClient, currently used to launch the client' %} {% trans 'JumpServerClient, currently used to launch the client' %}
</p> </p>
<ul> <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_en-US.msi">JumpServerClient_{{ CLIENT_VERSION }}_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 }}_x64-setup.exe">JumpServerClient_{{ CLIENT_VERSION }}_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 }}_aarch64.dmg">JumpServerClient_{{ CLIENT_VERSION }}_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 }}_x64.dmg">JumpServerClient_{{ CLIENT_VERSION }}_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.AppImage">JumpServerClient_{{ CLIENT_VERSION }}_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 }}_amd64.deb">JumpServerClient_{{ CLIENT_VERSION }}_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 }}-1.x86_64.rpm">JumpServerClient-{{ CLIENT_VERSION }}-1.x86_64.rpm</a></li>
</ul> </ul>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -37,12 +37,25 @@ logger = get_logger(__name__)
class UserOtpEnableStartView(AuthMixin, TemplateView): class UserOtpEnableStartView(AuthMixin, TemplateView):
template_name = 'users/user_otp_check_password.html' 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): def get(self, request, *args, **kwargs):
try: try:
self.get_user_from_session() self.get_user_from_session()
except SessionEmptyError: except SessionEmptyError:
url = reverse('authentication:login') + '?_=otp_enable_start' url = reverse('authentication:login') + '?_=otp_enable_start'
return redirect(url) return redirect(url)
if not MFAOtp.global_enabled():
return redirect(self.get_redirect_url())
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)