Compare commits

..

15 Commits

Author SHA1 Message Date
dependabot[bot]
ce107a0f39 chore(deps): bump werkzeug from 3.0.6 to 3.1.4
Bumps [werkzeug](https://github.com/pallets/werkzeug) from 3.0.6 to 3.1.4.
- [Release notes](https://github.com/pallets/werkzeug/releases)
- [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/werkzeug/compare/3.0.6...3.1.4)

---
updated-dependencies:
- dependency-name: werkzeug
  dependency-version: 3.1.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 04:39:21 +00:00
Bai
045ca8807a feat: modify client redirect url 2025-12-01 19:04:19 +08:00
Bai
19a68d8930 feat: add api access token 2025-12-01 17:55:08 +08:00
Bai
75ed02a2d2 feat: add oauth2 provider accesstokens api 2025-12-01 17:55:08 +08:00
fit2bot
f420dac49c feat: Host cloud sync supports state cloud - i18n (#16304)
Co-authored-by: jiangweidong <1053570670@qq.com>
Co-authored-by: Jiangjie Bai <jiangjie.bai@fit2cloud.com>
2025-12-01 10:56:14 +08:00
Bai
1ee68134f2 fix: rename utils methond 2025-12-01 10:41:14 +08:00
Bai
937265db5d perf: add period task clear oauth2 provider expired tokens 2025-12-01 10:41:14 +08:00
Bai
c611d5e88b perf: add utils delete oauth2 provider application 2025-12-01 10:41:14 +08:00
Bai
883b6b6383 perf: skip_authorization for redirect to jms client 2025-12-01 10:41:14 +08:00
Bai
ac4c72064f perf: register jumpserver client logic 2025-12-01 10:41:14 +08:00
Bai
dbf8360e27 feat: add OAUTH2_PROVIDER_ACCESS_TOKEN_EXPIRE_SECONDS 2025-12-01 10:41:14 +08:00
github-actions[bot]
150d7a09bc perf: Update Dockerfile with new base image tag 2025-11-28 16:28:23 +08:00
Bai
a7ed20e059 perf: support as oauth2 provider 2025-11-28 16:28:23 +08:00
github-actions[bot]
1b7b8e6f2e perf: Update Dockerfile with new base image tag 2025-11-28 16:28:23 +08:00
Bai
cd22fbce19 perf: support as oauth2 provider 2025-11-28 16:28:23 +08:00
30 changed files with 176 additions and 84 deletions

View File

@@ -1,4 +1,4 @@
FROM jumpserver/core-base:20251113_092612 AS stage-build FROM jumpserver/core-base:20251128_025056 AS stage-build
ARG VERSION ARG VERSION

View File

@@ -16,3 +16,4 @@ from .sso import *
from .temp_token import * from .temp_token import *
from .token import * from .token import *
from .face import * from .face import *
from .access_token import *

View File

@@ -0,0 +1,32 @@
from django.utils.translation import gettext as _
from django.utils import timezone
from rest_framework.response import Response
from rest_framework.decorators import action
from rest_framework.status import HTTP_200_OK
from rbac.permissions import RBACPermission
from common.api import JMSModelViewSet
from ..serializers import AccessTokenSerializer
from oauth2_provider.models import get_access_token_model
AccessToken = get_access_token_model()
class AccessTokenViewSet(JMSModelViewSet):
serializer_class = AccessTokenSerializer
permission_classes = [RBACPermission]
http_method_names = ['get', 'options', 'delete']
rbac_perms = {
'revoke': 'oauth2_provider.delete_accesstoken',
}
def get_queryset(self):
return AccessToken.objects.filter(user=self.request.user).order_by('-created')
@action(methods=['DELETE'], detail=True, url_path='revoke')
def revoke(self, *args, **kwargs):
token = AccessToken.objects.filter(id=kwargs['pk']).first()
if not token or token.user != self.request.user:
return Response({ "detail": _("Access token not found.") }, status=HTTP_200_OK)
token = token.refresh_token or token
token.revoke()
return Response( {"detail": _("Token revoked successfully.")}, status=HTTP_200_OK)

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, required=False) mfa_type = forms.CharField(label=_('MFA type'), max_length=128)
class CustomCaptchaTextInput(CaptchaTextInput): class CustomCaptchaTextInput(CaptchaTextInput):

View File

@@ -72,9 +72,10 @@ class BaseMFA(abc.ABC):
def is_active(self): def is_active(self):
return False return False
@classmethod @staticmethod
def global_enabled(cls): @abc.abstractmethod
return cls.name in settings.SECURITY_MFA_ENABLED_BACKENDS def global_enabled():
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
@classmethod @staticmethod
def global_enabled(cls): def global_enabled():
return super().global_enabled() and settings.MFA_CUSTOM and callable(mfa_custom_method) return 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()
@classmethod @staticmethod
def global_enabled(cls): def global_enabled():
return super().global_enabled and settings.SECURITY_MFA_BY_EMAIL return settings.SECURITY_MFA_BY_EMAIL
def disable(self): def disable(self):
return '/ui/#/profile/index' return '/ui/#/profile/index'

View File

@@ -29,10 +29,9 @@ class MFAFace(BaseMFA, AuthFaceMixin):
return True return True
return bool(self.user.face_vector) return bool(self.user.face_vector)
@classmethod @staticmethod
def global_enabled(cls): def global_enabled():
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,6 +25,10 @@ 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()
@classmethod @staticmethod
def global_enabled(cls): def global_enabled():
return super().global_enabled() and settings.AUTH_PASSKEY return 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
@classmethod @staticmethod
def global_enabled(cls): def global_enabled():
return super().global_enabled() and settings.OTP_IN_RADIUS return 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()
@classmethod @staticmethod
def global_enabled(cls): def global_enabled():
return super().global_enabled() and settings.SMS_ENABLED return 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

@@ -9,11 +9,12 @@ from common.utils import get_object_or_none, random_string
from users.models import User from users.models import User
from users.serializers import UserProfileSerializer from users.serializers import UserProfileSerializer
from ..models import AccessKey, TempToken from ..models import AccessKey, TempToken
from oauth2_provider.models import get_access_token_model
__all__ = [ __all__ = [
'AccessKeySerializer', 'BearerTokenSerializer', 'AccessKeySerializer', 'BearerTokenSerializer',
'SSOTokenSerializer', 'TempTokenSerializer', 'SSOTokenSerializer', 'TempTokenSerializer',
'AccessKeyCreateSerializer' 'AccessKeyCreateSerializer', 'AccessTokenSerializer',
] ]
@@ -114,3 +115,28 @@ class TempTokenSerializer(serializers.ModelSerializer):
token = TempToken(**kwargs) token = TempToken(**kwargs)
token.save() token.save()
return token return token
class AccessTokenSerializer(serializers.ModelSerializer):
token_preview = serializers.SerializerMethodField(label=_("Token"))
class Meta:
model = get_access_token_model()
fields = [
'id', 'user', 'token_preview', 'is_valid',
'is_expired', 'expires', 'scope', 'created', 'updated',
]
read_only_fields = fields
extra_kwargs = {
'scope': { 'label': _('Scope') },
'expires': { 'label': _('Date expired') },
'updated': { 'label': _('Date updated') },
'created': { 'label': _('Date created') },
}
def get_token_preview(self, obj):
token_string = obj.token
if len(token_string) > 16:
return f"{token_string[:6]}...{token_string[-4:]}"
return "****"

View File

@@ -47,3 +47,9 @@ def clean_expire_token():
count = TempToken.objects.filter(date_expired__lt=expired_time).delete() count = TempToken.objects.filter(date_expired__lt=expired_time).delete()
logging.info('Deleted %d temporary tokens.', count[0]) logging.info('Deleted %d temporary tokens.', count[0])
logging.info('Cleaned expired temporary and connection tokens.') logging.info('Cleaned expired temporary and connection tokens.')
@register_as_period_task(crontab=CRONTAB_AT_AM_TWO)
def clear_oauth2_provider_expired_tokens():
from oauth2_provider.models import clear_expired
clear_expired()

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 and mfa_backends %} {% elif form.mfa_type %}
<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

@@ -16,6 +16,8 @@ router.register('super-connection-token', api.SuperConnectionTokenViewSet, 'supe
router.register('admin-connection-token', api.AdminConnectionTokenViewSet, 'admin-connection-token') router.register('admin-connection-token', api.AdminConnectionTokenViewSet, 'admin-connection-token')
router.register('confirm', api.UserConfirmationViewSet, 'confirm') router.register('confirm', api.UserConfirmationViewSet, 'confirm')
router.register('ssh-key', api.SSHkeyViewSet, 'ssh-key') router.register('ssh-key', api.SSHkeyViewSet, 'ssh-key')
# oauth2-provider
router.register('access-tokens', api.AccessTokenViewSet, 'access-token')
urlpatterns = [ urlpatterns = [
path('<str:backend>/qr/unbind/', api.QRUnBindForUserApi.as_view(), name='qr-unbind'), path('<str:backend>/qr/unbind/', api.QRUnBindForUserApi.as_view(), name='qr-unbind'),

View File

@@ -5,6 +5,7 @@ import sys
from django.apps import AppConfig from django.apps import AppConfig
from django.db import close_old_connections from django.db import close_old_connections
from django.conf import settings
class CommonConfig(AppConfig): class CommonConfig(AppConfig):
@@ -23,3 +24,21 @@ class CommonConfig(AppConfig):
if not os.environ.get('DJANGO_DEBUG_SHELL'): if not os.environ.get('DJANGO_DEBUG_SHELL'):
django_ready.send(CommonConfig) django_ready.send(CommonConfig)
close_old_connections() close_old_connections()
self._auto_register_jumpserver_client_if_not_exists()
def _auto_register_jumpserver_client_if_not_exists(self):
""" Auto register JumpServer Client application if not exists. """
from oauth2_provider.models import get_application_model
Application = get_application_model()
client_id = settings.OAUTH2_PROVIDER_CLIENT_ID
if Application.objects.filter(client_id=client_id).exists():
return
Application.objects.create(
name='JumpServer Client',
client_id=client_id,
client_type=Application.CLIENT_PUBLIC,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
redirect_uris=settings.OAUTH2_PROVIDER_CLIENT_REDIRECT_URI,
skip_authorization=True,
)

View File

@@ -280,7 +280,8 @@
"CACertificate": "Ca certificate", "CACertificate": "Ca certificate",
"CAS": "CAS", "CAS": "CAS",
"CMPP2": "Cmpp v2.0", "CMPP2": "Cmpp v2.0",
"CTYunPrivate": "eCloud Private Cloud", "CTYun": "State Cloud",
"CTYunPrivate": "State Cloud(Private)",
"CalculationResults": "Error in cron expression", "CalculationResults": "Error in cron expression",
"CallRecords": "Call Records", "CallRecords": "Call Records",
"CanDragSelect": "Select by dragging; Empty means all selected", "CanDragSelect": "Select by dragging; Empty means all selected",
@@ -1634,5 +1635,8 @@
"selectedAssets": "Selected assets", "selectedAssets": "Selected assets",
"setVariable": "Set variable", "setVariable": "Set variable",
"userId": "User ID", "userId": "User ID",
"userName": "User name" "userName": "User name",
"AccessToken": "Access tokens",
"AccessTokenTip": "Access Token is a temporary credential generated through the OAuth2 (Authorization Code Grant) flow using the JumpServer client, which is used to access protected resources.",
"Revoke": "Revoke"
} }

View File

@@ -279,6 +279,7 @@
"CACertificate": "CA 证书", "CACertificate": "CA 证书",
"CAS": "CAS", "CAS": "CAS",
"CMPP2": "CMPP v2.0", "CMPP2": "CMPP v2.0",
"CTYun": "天翼云",
"CTYunPrivate": "天翼私有云", "CTYunPrivate": "天翼私有云",
"CalculationResults": "cron 表达式错误", "CalculationResults": "cron 表达式错误",
"CallRecords": "调用记录", "CallRecords": "调用记录",
@@ -1644,5 +1645,8 @@
"userId": "用户ID", "userId": "用户ID",
"userName": "用户名", "userName": "用户名",
"Risk": "风险", "Risk": "风险",
"selectFiles": "已选择选择{number}文件" "selectFiles": "已选择选择{number}文件",
"AccessToken": "访问令牌",
"AccessTokenTip": "访问令牌是通过 JumpServer 客户端使用 OAuth2授权码授权流程生成的临时凭证用于访问受保护的资源。",
"Revoke": "撤销"
} }

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_ENABLED_BACKENDS': [], 'SECURITY_MFA_BY_EMAIL': False,
'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'
@@ -735,6 +735,10 @@ class Config(dict):
# MCP # MCP
'MCP_ENABLED': False, 'MCP_ENABLED': False,
# oauth2_provider settings
'OAUTH2_PROVIDER_ACCESS_TOKEN_EXPIRE_SECONDS': 36000,
'OAUTH2_PROVIDER_REFRESH_TOKEN_EXPIRE_SECONDS': 2592000,
} }
old_config_map = { old_config_map = {

View File

@@ -141,6 +141,7 @@ INSTALLED_APPS = [
'drf_spectacular', 'drf_spectacular',
'drf_spectacular_sidecar', 'drf_spectacular_sidecar',
'django_cas_ng', 'django_cas_ng',
'oauth2_provider',
'channels', 'channels',
'django_filters', 'django_filters',
'bootstrap3', 'bootstrap3',

View File

@@ -30,6 +30,7 @@ REST_FRAMEWORK = {
), ),
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
# 'rest_framework.authentication.BasicAuthentication', # 'rest_framework.authentication.BasicAuthentication',
"oauth2_provider.contrib.rest_framework.OAuth2Authentication",
'authentication.backends.drf.AccessTokenAuthentication', 'authentication.backends.drf.AccessTokenAuthentication',
'authentication.backends.drf.PrivateTokenAuthentication', 'authentication.backends.drf.PrivateTokenAuthentication',
'authentication.backends.drf.ServiceAuthentication', 'authentication.backends.drf.ServiceAuthentication',
@@ -222,3 +223,13 @@ PIICO_DRIVER_PATH = CONFIG.PIICO_DRIVER_PATH
LEAK_PASSWORD_DB_PATH = CONFIG.LEAK_PASSWORD_DB_PATH LEAK_PASSWORD_DB_PATH = CONFIG.LEAK_PASSWORD_DB_PATH
JUMPSERVER_UPTIME = int(time.time()) JUMPSERVER_UPTIME = int(time.time())
# OAuth2 Provider settings
OAUTH2_PROVIDER = {
'ALLOWED_REDIRECT_URI_SCHEMES': ['https', 'jms'],
'PKCE_REQUIRED': True,
'ACCESS_TOKEN_EXPIRE_SECONDS': CONFIG.OAUTH2_PROVIDER_ACCESS_TOKEN_EXPIRE_SECONDS,
'REFRESH_TOKEN_EXPIRE_SECONDS': CONFIG.OAUTH2_PROVIDER_REFRESH_TOKEN_EXPIRE_SECONDS,
}
OAUTH2_PROVIDER_CLIENT_ID = 'FkkXFf0wPelYPIbvf0VElkZtyrw8TWIcyqakDgni'
OAUTH2_PROVIDER_CLIENT_REDIRECT_URI = 'jms://auth/callback'

View File

@@ -54,6 +54,7 @@ app_view_patterns = [
path('download/', views.ResourceDownload.as_view(), name='download'), path('download/', views.ResourceDownload.as_view(), name='download'),
path('redirect/confirm/', views.RedirectConfirm.as_view(), name='redirect-confirm'), path('redirect/confirm/', views.RedirectConfirm.as_view(), name='redirect-confirm'),
path('i18n/<str:lang>/', views.I18NView.as_view(), name='i18n-switch'), path('i18n/<str:lang>/', views.I18NView.as_view(), name='i18n-switch'),
path('oauth2-provider/', include('oauth2_provider.urls', namespace='oauth2-provider')),
] ]
if settings.XPACK_ENABLED: if settings.XPACK_ENABLED:

View File

@@ -148,6 +148,6 @@ class RedirectConfirm(TemplateView):
parsed = urlparse(url) parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc: if not parsed.scheme or not parsed.netloc:
return False return False
if parsed.scheme not in ['http', 'https']: if parsed.scheme not in ['http', 'https', 'jms']:
return False return False
return True return True

View File

@@ -133,6 +133,11 @@ exclude_permissions = (
('terminal', 'session', 'delete,change', 'command'), ('terminal', 'session', 'delete,change', 'command'),
('applications', '*', '*', '*'), ('applications', '*', '*', '*'),
('settings', 'chatprompt', 'add,delete,change', 'chatprompt'), ('settings', 'chatprompt', 'add,delete,change', 'chatprompt'),
('oauth2_provider', 'grant', '*', '*'),
('oauth2_provider', 'refreshtoken', '*', '*'),
('oauth2_provider', 'idtoken', '*', '*'),
('oauth2_provider', 'application', '*', '*'),
('oauth2_provider', 'accesstoken', 'add,change', 'accesstoken')
) )
only_system_permissions = ( only_system_permissions = (
@@ -160,6 +165,7 @@ only_system_permissions = (
('authentication', 'temptoken', '*', '*'), ('authentication', 'temptoken', '*', '*'),
('authentication', 'passkey', '*', '*'), ('authentication', 'passkey', '*', '*'),
('authentication', 'ssotoken', '*', '*'), ('authentication', 'ssotoken', '*', '*'),
('oauth2_provider', 'accesstoken', '*', '*'),
('tickets', '*', '*', '*'), ('tickets', '*', '*', '*'),
('orgs', 'organization', 'view', 'rootorg'), ('orgs', 'organization', 'view', 'rootorg'),
('terminal', 'applet', '*', '*'), ('terminal', 'applet', '*', '*'),

View File

@@ -129,6 +129,7 @@ special_pid_mapper = {
"rbac.view_systemtools": "view_workbench", "rbac.view_systemtools": "view_workbench",
'tickets.view_ticket': 'tickets', 'tickets.view_ticket': 'tickets',
"audits.joblog": "job_audit", "audits.joblog": "job_audit",
'oauth2_provider.accesstoken': 'authentication',
} }
special_setting_pid_mapper = { special_setting_pid_mapper = {
@@ -184,6 +185,8 @@ verbose_name_mapper = {
'tickets.view_ticket': _("Ticket"), 'tickets.view_ticket': _("Ticket"),
'settings.setting': _("Common setting"), 'settings.setting': _("Common setting"),
'rbac.view_permission': _('View permission tree'), 'rbac.view_permission': _('View permission tree'),
'authentication.passkey': _("Passkey"),
'oauth2_provider.accesstoken': _("Access token"),
} }
xpack_nodes = [ xpack_nodes = [

View File

@@ -1,7 +1,3 @@
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
@@ -121,35 +117,6 @@ 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=(
@@ -163,10 +130,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_ENABLED_BACKENDS = DynamicMFAChoiceField( SECURITY_MFA_BY_EMAIL = serializers.BooleanField(
default=[], allow_empty=True, required=False, default=False,
label=_('MFA Backends'), label=_('MFA via Email'),
help_text=_('MFA methods supported for user login') help_text=_('Email as a method for multi-factor authentication')
) )
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

@@ -37,25 +37,12 @@ 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)

View File

@@ -45,7 +45,7 @@ dependencies = [
'sshpubkeys==3.3.1', 'sshpubkeys==3.3.1',
'uritemplate==4.1.1', 'uritemplate==4.1.1',
'vine==5.0.0', 'vine==5.0.0',
'werkzeug==3.0.6', 'werkzeug==3.1.4',
'unicodecsv==0.14.1', 'unicodecsv==0.14.1',
'httpsig==1.3.0', 'httpsig==1.3.0',
'treelib==1.6.4', 'treelib==1.6.4',
@@ -84,7 +84,7 @@ dependencies = [
'rest-condition==1.0.3', 'rest-condition==1.0.3',
'drf-spectacular==0.28.0', 'drf-spectacular==0.28.0',
'pillow==10.2.0', 'pillow==10.2.0',
'pytz==2023.3', 'pytz==2025.2',
'django-proxy==1.2.2', 'django-proxy==1.2.2',
'python-daemon==3.0.1', 'python-daemon==3.0.1',
'eventlet==0.40.3', 'eventlet==0.40.3',
@@ -152,6 +152,7 @@ dependencies = [
'playwright==1.55.0', 'playwright==1.55.0',
'pdf2image==1.17.0', 'pdf2image==1.17.0',
'drf-spectacular-sidecar==2025.8.1', 'drf-spectacular-sidecar==2025.8.1',
"django-oauth-toolkit==2.4.0",
] ]
[project.urls] [project.urls]

View File

@@ -0,0 +1,13 @@
#!/bin/bash
#
function delete_oauth2_provider_applications() {
python3 ../apps/manage.py shell << EOF
from oauth2_provider.models import *
apps = Application.objects.all()
apps.delete()
print("OAuth2 Provider Applications deleted successfully!")
EOF
}
delete_oauth2_provider_applications