mirror of
https://github.com/jumpserver/jumpserver.git
synced 2026-07-16 00:10:55 +00:00
fix: allow OTP rebind after MFA reset with active MFA verification
This commit is contained in:
@@ -6,6 +6,7 @@ USER_LOGIN_GUARD_VIEW_REDIRECT_FIELD = 'next'
|
||||
|
||||
RSA_PRIVATE_KEY = 'rsa_private_key'
|
||||
RSA_PUBLIC_KEY = 'rsa_public_key'
|
||||
OTP_BIND_AFTER_MFA_SESSION_KEY = 'auth_bind_otp_after_mfa'
|
||||
|
||||
|
||||
class ConfirmType(TextChoices):
|
||||
|
||||
@@ -32,6 +32,7 @@ from common.utils import (
|
||||
from users.models import User
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils, LoginIpBlockUtil
|
||||
from . import errors
|
||||
from .const import OTP_BIND_AFTER_MFA_SESSION_KEY
|
||||
from .signals import post_auth_success, post_auth_failed
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -726,7 +727,7 @@ class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, AuthFaceMixin, MFA
|
||||
'auth_password', 'user_id', 'auth_confirm_required',
|
||||
'auth_notice_required', 'auth_ticket_id', 'auth_acl_id',
|
||||
'user_session_id', 'user_log_id', 'can_send_notifications',
|
||||
'auth_ukey'
|
||||
'auth_ukey', OTP_BIND_AFTER_MFA_SESSION_KEY
|
||||
]
|
||||
for k in keys:
|
||||
self.request.session.pop(k, '')
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.shortcuts import redirect, reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic.edit import FormView
|
||||
|
||||
from common.utils import get_logger
|
||||
from users.views import UserFaceCaptureView
|
||||
from .utils import redirect_to_guard_view
|
||||
from .. import forms, errors, mixins
|
||||
from ..const import MFAType
|
||||
from ..const import MFAType, OTP_BIND_AFTER_MFA_SESSION_KEY
|
||||
|
||||
logger = get_logger(__name__)
|
||||
__all__ = ['UserLoginMFAView', 'UserLoginMFAFaceView']
|
||||
@@ -34,9 +35,48 @@ class UserLoginMFAView(mixins.AuthMixin, FormView):
|
||||
|
||||
return super().get(*args, **kwargs)
|
||||
|
||||
def _get_otp_enable_url(self):
|
||||
url = reverse('authentication:user-otp-enable-start')
|
||||
query_string = self.request.GET.urlencode()
|
||||
if query_string:
|
||||
url = f'{url}?{query_string}'
|
||||
return url
|
||||
|
||||
@staticmethod
|
||||
def _has_active_mfa_except_otp(user):
|
||||
return any(
|
||||
backend.name != MFAType.OTP.value
|
||||
for backend in user.active_mfa_backends
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_inactive_otp(user, mfa_type):
|
||||
if mfa_type != MFAType.OTP:
|
||||
return False
|
||||
otp_backend = user.get_mfa_backend_by_type(MFAType.OTP.value)
|
||||
return otp_backend and not otp_backend.is_active()
|
||||
|
||||
def _handle_inactive_otp(self, form, user):
|
||||
self.request.session[OTP_BIND_AFTER_MFA_SESSION_KEY] = 1
|
||||
if not self._has_active_mfa_except_otp(user):
|
||||
return redirect(self._get_otp_enable_url())
|
||||
|
||||
form.add_error(
|
||||
'code',
|
||||
_('Please verify an active MFA before binding OTP.')
|
||||
)
|
||||
return super().form_invalid(form)
|
||||
|
||||
def form_valid(self, form):
|
||||
code = form.cleaned_data.get('code')
|
||||
mfa_type = form.cleaned_data.get('mfa_type')
|
||||
try:
|
||||
user = self.get_user_from_session()
|
||||
except errors.SessionEmptyError:
|
||||
return redirect_to_guard_view('session_empty')
|
||||
|
||||
if self._is_inactive_otp(user, mfa_type):
|
||||
return self._handle_inactive_otp(form, user)
|
||||
|
||||
if mfa_type == MFAType.Face:
|
||||
return redirect(reverse('authentication:login-face-capture'))
|
||||
@@ -51,6 +91,9 @@ class UserLoginMFAView(mixins.AuthMixin, FormView):
|
||||
self._do_check_user_mfa(code, mfa_type)
|
||||
user, ip = self.get_user_from_session(), self.get_request_ip()
|
||||
MFABlockUtils(user.username, ip).clean_failed_count()
|
||||
if self.request.session.pop(OTP_BIND_AFTER_MFA_SESSION_KEY, None):
|
||||
return redirect(self._get_otp_enable_url())
|
||||
|
||||
query_string = self.request.GET.urlencode()
|
||||
return redirect_to_guard_view('mfa_ok', query_string)
|
||||
except (errors.MFAFailedError, errors.BlockMFAError) as e:
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
>
|
||||
{% for backend in mfa_backends %}
|
||||
<option value="{{ backend.name }}"
|
||||
{% if not backend.is_active %} disabled {% endif %}
|
||||
data-needs-bind="{% if backend.name == 'otp' and not backend.is_active %}true{% else %}false{% endif %}"
|
||||
{% if not backend.is_active and backend.name != 'otp' %} disabled {% endif %}
|
||||
>
|
||||
{{ backend.display_name }}
|
||||
</option>
|
||||
@@ -18,7 +19,9 @@
|
||||
{% if backend.challenge_required %}challenge-required{% endif %}"
|
||||
style="display: none"
|
||||
>
|
||||
{% if backend.has_code %}
|
||||
{% if backend.name == 'otp' and not backend.is_active %}
|
||||
<small class="text-muted">{% trans 'Enable OTP' %}</small>
|
||||
{% elif backend.has_code %}
|
||||
<input type="text" class="form-control input-style"
|
||||
placeholder="{{ backend.placeholder }}"
|
||||
autocomplete="off"
|
||||
@@ -54,17 +57,52 @@
|
||||
</style>
|
||||
<script>
|
||||
const preferMFAKey = 'mfaPrefer'
|
||||
|
||||
function getMFAOption(selectRef, value) {
|
||||
let optionRef = null
|
||||
$(selectRef).find('option').each(function () {
|
||||
if (this.value === value) {
|
||||
optionRef = this
|
||||
return false
|
||||
}
|
||||
})
|
||||
return optionRef
|
||||
}
|
||||
|
||||
function optionNeedsBind(optionRef) {
|
||||
return optionRef && optionRef.dataset.needsBind === 'true'
|
||||
}
|
||||
|
||||
function getFirstReadyMFAOption(selectRef) {
|
||||
let optionRef = null
|
||||
$(selectRef).find('option').each(function () {
|
||||
if (!this.disabled && !optionNeedsBind(this)) {
|
||||
optionRef = this
|
||||
return false
|
||||
}
|
||||
})
|
||||
return optionRef
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
const mfaSelectRef = document.getElementById('mfa-select');
|
||||
const preferMFA = localStorage.getItem(preferMFAKey);
|
||||
const valueSelector = "value=" + preferMFA
|
||||
const preferMFADisabled = $(`#mfa-select option[${valueSelector}]`).attr('disabled')
|
||||
const preferMFAOption = getMFAOption(mfaSelectRef, preferMFA)
|
||||
|
||||
if (preferMFA && !preferMFADisabled) {
|
||||
if (preferMFAOption && !preferMFAOption.disabled && !optionNeedsBind(preferMFAOption)) {
|
||||
mfaSelectRef.value = preferMFA;
|
||||
}
|
||||
|
||||
const mfaSelect = mfaSelectRef.value;
|
||||
let mfaSelect = mfaSelectRef.value;
|
||||
const selectedOption = getMFAOption(mfaSelectRef, mfaSelect)
|
||||
if (optionNeedsBind(selectedOption)) {
|
||||
const readyOption = getFirstReadyMFAOption(mfaSelectRef)
|
||||
if (readyOption) {
|
||||
mfaSelectRef.value = readyOption.value
|
||||
mfaSelect = readyOption.value
|
||||
}
|
||||
}
|
||||
|
||||
if (mfaSelect !== null) {
|
||||
selectChange(mfaSelect, true);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.utils.translation import gettext as _
|
||||
from django.views.generic.base import TemplateView
|
||||
from django.views.generic.edit import FormView
|
||||
|
||||
from authentication.const import MFAType, OTP_BIND_AFTER_MFA_SESSION_KEY
|
||||
from authentication.errors import SessionEmptyError
|
||||
from authentication.mfa import MFAOtp, otp_failed_msg
|
||||
from authentication.mixins import AuthMixin
|
||||
@@ -34,21 +35,73 @@ __all__ = [
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UserOtpEnableStartView(AuthMixin, TemplateView):
|
||||
class OTPBindMFACheckMixin(AuthMixin):
|
||||
@staticmethod
|
||||
def _is_request_user_authenticated(request):
|
||||
user = getattr(request, 'user', None)
|
||||
return bool(user and user.is_authenticated)
|
||||
|
||||
@staticmethod
|
||||
def _has_active_mfa_except_otp(user):
|
||||
return any(
|
||||
backend.name != MFAType.OTP.value
|
||||
for backend in user.active_mfa_backends
|
||||
)
|
||||
|
||||
def _need_mfa_before_otp_bind(self, user):
|
||||
if self.request.session.get('auth_mfa'):
|
||||
return False
|
||||
if self._is_request_user_authenticated(self.request) and \
|
||||
not self.request.session.get('auth_mfa_required'):
|
||||
return False
|
||||
return self._has_active_mfa_except_otp(user)
|
||||
|
||||
def _get_login_mfa_url(self):
|
||||
url = reverse('authentication:login-mfa')
|
||||
query_string = self.request.GET.urlencode()
|
||||
if query_string:
|
||||
url = f'{url}?{query_string}'
|
||||
return url
|
||||
|
||||
def _pre_check_need_mfa_for_otp_bind(self, user):
|
||||
if not self._need_mfa_before_otp_bind(user):
|
||||
return None
|
||||
|
||||
self.request.session[OTP_BIND_AFTER_MFA_SESSION_KEY] = 1
|
||||
return HttpResponseRedirect(self._get_login_mfa_url())
|
||||
|
||||
|
||||
class UserOtpEnableStartView(OTPBindMFACheckMixin, TemplateView):
|
||||
template_name = 'users/user_otp_check_password.html'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
try:
|
||||
self.get_user_from_session()
|
||||
user = self.get_user_from_session()
|
||||
except SessionEmptyError:
|
||||
url = reverse('authentication:login') + '?_=otp_enable_start'
|
||||
return redirect(url)
|
||||
|
||||
pre_response = self._pre_check_need_mfa_for_otp_bind(user)
|
||||
if pre_response:
|
||||
return pre_response
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class UserOtpEnableInstallAppView(TemplateView):
|
||||
class UserOtpEnableInstallAppView(OTPBindMFACheckMixin, TemplateView):
|
||||
template_name = 'users/user_otp_enable_install_app.html'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
try:
|
||||
user = self.get_user_from_session()
|
||||
except SessionEmptyError as e:
|
||||
verify_url = reverse('authentication:user-otp-enable-start') + f'?e={e}'
|
||||
return HttpResponseRedirect(verify_url)
|
||||
|
||||
pre_response = self._pre_check_need_mfa_for_otp_bind(user)
|
||||
if pre_response:
|
||||
return pre_response
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def replace_authenticator_png(platform):
|
||||
media_url = settings.MEDIA_URL
|
||||
@@ -73,7 +126,7 @@ class UserOtpEnableInstallAppView(TemplateView):
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class UserOtpEnableBindView(AuthMixin, TemplateView, FormView):
|
||||
class UserOtpEnableBindView(OTPBindMFACheckMixin, TemplateView, FormView):
|
||||
template_name = 'users/user_otp_enable_bind.html'
|
||||
form_class = forms.UserCheckOtpCodeForm
|
||||
|
||||
@@ -98,6 +151,9 @@ class UserOtpEnableBindView(AuthMixin, TemplateView, FormView):
|
||||
|
||||
if user.otp_secret_key:
|
||||
return self.has_already_bound_message()
|
||||
pre_response = self._pre_check_need_mfa_for_otp_bind(user)
|
||||
if pre_response:
|
||||
return pre_response
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
||||
Reference in New Issue
Block a user