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 %}