fix: allow OTP rebind after MFA reset with active MFA verification

This commit is contained in:
feng
2026-07-13 11:05:15 +08:00
committed by Jiangjie Bai
parent ced3952018
commit a7a0302583
5 changed files with 151 additions and 12 deletions

View File

@@ -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):

View File

@@ -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, '')

View File

@@ -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:

View File

@@ -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);
}

View File

@@ -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