From a7a03025834a10071edc0291d126c6db20363d44 Mon Sep 17 00:00:00 2001
From: feng <1304903146@qq.com>
Date: Mon, 13 Jul 2026 11:05:15 +0800
Subject: [PATCH] fix: allow OTP rebind after MFA reset with active MFA
verification
---
apps/authentication/const.py | 1 +
apps/authentication/mixins.py | 3 +-
apps/authentication/views/mfa.py | 45 ++++++++++++++++++-
apps/templates/_mfa_login_field.html | 50 +++++++++++++++++++---
apps/users/views/profile/otp.py | 64 ++++++++++++++++++++++++++--
5 files changed, 151 insertions(+), 12 deletions(-)
diff --git a/apps/authentication/const.py b/apps/authentication/const.py
index 0467da746..9a88c836f 100644
--- a/apps/authentication/const.py
+++ b/apps/authentication/const.py
@@ -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):
diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py
index 1c1cc9676..743eda2ae 100644
--- a/apps/authentication/mixins.py
+++ b/apps/authentication/mixins.py
@@ -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, '')
diff --git a/apps/authentication/views/mfa.py b/apps/authentication/views/mfa.py
index 4898cc093..eb5e64eb3 100644
--- a/apps/authentication/views/mfa.py
+++ b/apps/authentication/views/mfa.py
@@ -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:
diff --git a/apps/templates/_mfa_login_field.html b/apps/templates/_mfa_login_field.html
index 40b73ade4..72b171ed7 100644
--- a/apps/templates/_mfa_login_field.html
+++ b/apps/templates/_mfa_login_field.html
@@ -6,7 +6,8 @@
>
{% for backend in mfa_backends %}
@@ -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 %}
+ {% trans 'Enable OTP' %}
+ {% elif backend.has_code %}