Initial mfa support (for internal users) #93591

Merged
Oleg-Komarov merged 46 commits from mfa into main 2024-08-29 11:44:06 +02:00
6 changed files with 50 additions and 28 deletions
Showing only changes of commit 935c19f502 - Show all commits

View File

@ -1,7 +1,6 @@
from django.conf import settings
from django.urls import reverse_lazy, path, re_path
from django.contrib.auth import views as auth_views
from otp_agents.decorators import otp_required
from . import forms
from .views import normal_pages, registration_email, json_api, developer_applications
@ -149,11 +148,7 @@ urlpatterns = [
),
path(
'mfa/',
otp_required(
accept_trusted_agent=True,
if_configured=True,
view=normal_pages.MfaView.as_view(),
),
normal_pages.MfaView.as_view(),
name='mfa',
),
]

View File

@ -1,14 +1,14 @@
"""Pages for displaying and editing OAuth applications."""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.forms import inlineformset_factory
from django.urls import reverse
from django.views.generic import ListView
from django.views.generic.edit import UpdateView
import bid_main.forms
from bid_main.views import mixins
import bid_api.forms
import bid_api.models
import bid_main.forms
import bid_main.models
WebhookFormSet = inlineformset_factory(
@ -28,14 +28,21 @@ class _OwnedOAuth2ApplicationsMixin:
return self.request.user.bid_main_oauth2application
class ListApplicationsView(LoginRequiredMixin, _OwnedOAuth2ApplicationsMixin, ListView):
class ListApplicationsView(
mixins.MfaRequiredIfConfiguredMixin,
_OwnedOAuth2ApplicationsMixin,
ListView,
):
"""List all OAuth 2 applications that currently logged in user is allowed to manage."""
template_name = 'bid_main/developer_applications.html'
class EditApplicationView(
LoginRequiredMixin, SuccessMessageMixin, _OwnedOAuth2ApplicationsMixin, UpdateView
mixins.MfaRequiredIfConfiguredMixin,
SuccessMessageMixin,
_OwnedOAuth2ApplicationsMixin,
UpdateView,
):
"""Edit an OAuth 2 application, if allowed."""

View File

@ -7,17 +7,17 @@ than via bearer tokens.
import logging
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.generic import View
from .. import models
from bid_main.views import mixins
log = logging.getLogger(__name__)
class BadgeTogglePrivateView(LoginRequiredMixin, View):
class BadgeTogglePrivateView(mixins.MfaRequiredIfConfiguredMixin, View):
"""JSON endpoint that toggles 'is_private' flag for badges."""
def post(self, request, *args, **kwargs) -> JsonResponse:

View File

@ -2,7 +2,9 @@
import logging
import urllib.parse
from django.contrib.auth.mixins import AccessMixin
from django.urls import reverse_lazy
from otp_agents.decorators import otp_required
log = logging.getLogger(__name__)
@ -35,3 +37,13 @@ class RedirectToPrivacyAgreeMixin:
redirect_to = f"{self.privacy_policy_agree_url}?{next_url_qs}"
log.debug("Directing user to %s", redirect_to)
return redirect_to
class MfaRequiredIfConfiguredMixin(AccessMixin):
"""Use this mixin instead of LoginRequiredMixin for bid_main.views."""
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
decorator = otp_required(accept_trusted_agent=True, if_configured=True)
return decorator(super().dispatch)(request, *args, **kwargs)

View File

@ -9,7 +9,6 @@ import urllib.parse
from django.conf import settings
from django.contrib.auth import views as auth_views, logout, get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from django.db import transaction, IntegrityError
@ -38,7 +37,7 @@ User = get_user_model()
log = logging.getLogger(__name__)
class IndexView(LoginRequiredMixin, mixins.PageIdMixin, TemplateView):
class IndexView(mixins.MfaRequiredIfConfiguredMixin, mixins.PageIdMixin, TemplateView):
page_id = "index"
template_name = "bid_main/index.html"
login_url = reverse_lazy("bid_main:login")
@ -230,7 +229,7 @@ class LogoutView(auth_views.LogoutView):
return next_url
class ProfileView(LoginRequiredMixin, UpdateView):
class ProfileView(mixins.MfaRequiredIfConfiguredMixin, UpdateView):
form_class = forms.UserProfileForm
model = User
template_name = "bid_main/profile.html"
@ -271,7 +270,11 @@ class ProfileView(LoginRequiredMixin, UpdateView):
return success_resp
class SwitchUserView(mixins.RedirectToPrivacyAgreeMixin, LoginRequiredMixin, auth_views.LoginView):
class SwitchUserView(
mixins.RedirectToPrivacyAgreeMixin,
mixins.MfaRequiredIfConfiguredMixin,
auth_views.LoginView,
):
template_name = "bid_main/switch_user.html"
form_class = forms.AuthenticationForm
success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS
@ -303,7 +306,12 @@ class GetAppsMixin:
return app_model.objects.filter(id__in=app_ids).order_by("name")
class ApplicationTokenView(mixins.PageIdMixin, LoginRequiredMixin, GetAppsMixin, FormView):
class ApplicationTokenView(
mixins.PageIdMixin,
mixins.MfaRequiredIfConfiguredMixin,
GetAppsMixin,
FormView,
):
page_id = "auth_tokens"
template_name = "bid_main/auth_tokens.html"
form_class = forms.AppRevokeTokensForm
@ -333,7 +341,7 @@ class ApplicationTokenView(mixins.PageIdMixin, LoginRequiredMixin, GetAppsMixin,
return super().form_valid(form)
class PrivacyPolicyAgreeView(mixins.PageIdMixin, LoginRequiredMixin, FormView):
class PrivacyPolicyAgreeView(mixins.PageIdMixin, mixins.MfaRequiredIfConfiguredMixin, FormView):
page_id = "privacy_policy_agree"
template_name = "bid_main/privacy_policy_agree.html"
form_class = forms.PrivacyPolicyAgreeForm
@ -374,7 +382,7 @@ class PrivacyPolicyAgreeView(mixins.PageIdMixin, LoginRequiredMixin, FormView):
class DeleteUserView(
mixins.RedirectToPrivacyAgreeMixin, LoginRequiredMixin, GetAppsMixin, FormView
mixins.RedirectToPrivacyAgreeMixin, mixins.MfaRequiredIfConfiguredMixin, GetAppsMixin, FormView
):
template_name = "bid_main/delete_user.html"
form_class = forms.DeleteForm
@ -420,7 +428,7 @@ class DeleteUserView(
return render(self.request, "bid_main/delete_user/confirm.html", context=ctx)
class ActiveSessionsView(LoginRequiredMixin, TemplateView):
class ActiveSessionsView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
template_name = "bid_main/active_sessions.html"
def get_context_data(self, **kwargs):
@ -434,7 +442,7 @@ class ActiveSessionsView(LoginRequiredMixin, TemplateView):
}
class TerminateSessionView(LoginRequiredMixin, View):
class TerminateSessionView(mixins.MfaRequiredIfConfiguredMixin, View):
def post(self, request, *args, **kwargs):
user_session_pk = kwargs.get('pk')
if user_session := self.request.user.sessions.filter(pk=user_session_pk).first():
@ -443,7 +451,7 @@ class TerminateSessionView(LoginRequiredMixin, View):
return HttpResponseNotFound("session not found")
class MfaView(LoginRequiredMixin, TemplateView):
class MfaView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
template_name = "bid_main/mfa_setup.html"
def get_context_data(self, **kwargs):

View File

@ -3,7 +3,6 @@ import logging
from django.contrib.auth import views as auth_views
from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError
from django.http import HttpResponseBadRequest, JsonResponse
@ -14,6 +13,7 @@ from django.utils import timezone
from django.views.generic import CreateView, TemplateView, FormView, View
from .. import forms, email
from . import mixins
from ..models import User
log = logging.getLogger(__name__)
@ -91,7 +91,7 @@ class InitialSetPasswordView(auth_views.PasswordResetConfirmView):
form_class = forms.SetInitialPasswordForm
class ConfirmEmailView(LoginRequiredMixin, FormView):
class ConfirmEmailView(mixins.MfaRequiredIfConfiguredMixin, FormView):
template_name = "bid_main/confirm_email/start.html"
form_class = forms.ConfirmEmailStartForm
log = logging.getLogger(f"{__name__}.ConfirmEmailView")
@ -127,7 +127,7 @@ class ConfirmEmailView(LoginRequiredMixin, FormView):
return redirect("bid_main:confirm-email-sent")
class CancelEmailChangeView(LoginRequiredMixin, View):
class CancelEmailChangeView(mixins.MfaRequiredIfConfiguredMixin, View):
"""Cancel the user's email change and redirect to the profile page."""
log = logging.getLogger(f"{__name__}.CancelEmailChangeView")
@ -142,11 +142,11 @@ class CancelEmailChangeView(LoginRequiredMixin, View):
return redirect("bid_main:index")
class ConfirmEmailSentView(LoginRequiredMixin, TemplateView):
class ConfirmEmailSentView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
template_name = "bid_main/confirm_email/sent.html"
class ConfirmEmailPollView(LoginRequiredMixin, View):
class ConfirmEmailPollView(mixins.MfaRequiredIfConfiguredMixin, View):
"""Returns JSON indicating when the email address has last been confirmed.
The timestamp is returned as ISO 8601 to allow future periodic checks
@ -167,7 +167,7 @@ class ConfirmEmailPollView(LoginRequiredMixin, View):
return JsonResponse({"confirmed": timestamp})
class ConfirmEmailVerifiedView(LoginRequiredMixin, TemplateView):
class ConfirmEmailVerifiedView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
"""Render explanation on GET, handle confirmation on POST.
We only perform the actual database change on a POST, since that protects