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

View File

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

View File

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

View File

@ -2,7 +2,9 @@
import logging import logging
import urllib.parse import urllib.parse
from django.contrib.auth.mixins import AccessMixin
from django.urls import reverse_lazy from django.urls import reverse_lazy
from otp_agents.decorators import otp_required
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -35,3 +37,13 @@ class RedirectToPrivacyAgreeMixin:
redirect_to = f"{self.privacy_policy_agree_url}?{next_url_qs}" redirect_to = f"{self.privacy_policy_agree_url}?{next_url_qs}"
log.debug("Directing user to %s", redirect_to) log.debug("Directing user to %s", redirect_to)
return 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.conf import settings
from django.contrib.auth import views as auth_views, logout, get_user_model 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.core.exceptions import ValidationError
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
@ -38,7 +37,7 @@ User = get_user_model()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class IndexView(LoginRequiredMixin, mixins.PageIdMixin, TemplateView): class IndexView(mixins.MfaRequiredIfConfiguredMixin, mixins.PageIdMixin, TemplateView):
page_id = "index" page_id = "index"
template_name = "bid_main/index.html" template_name = "bid_main/index.html"
login_url = reverse_lazy("bid_main:login") login_url = reverse_lazy("bid_main:login")
@ -230,7 +229,7 @@ class LogoutView(auth_views.LogoutView):
return next_url return next_url
class ProfileView(LoginRequiredMixin, UpdateView): class ProfileView(mixins.MfaRequiredIfConfiguredMixin, UpdateView):
form_class = forms.UserProfileForm form_class = forms.UserProfileForm
model = User model = User
template_name = "bid_main/profile.html" template_name = "bid_main/profile.html"
@ -271,7 +270,11 @@ class ProfileView(LoginRequiredMixin, UpdateView):
return success_resp 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" template_name = "bid_main/switch_user.html"
form_class = forms.AuthenticationForm form_class = forms.AuthenticationForm
success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS 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") 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" page_id = "auth_tokens"
template_name = "bid_main/auth_tokens.html" template_name = "bid_main/auth_tokens.html"
form_class = forms.AppRevokeTokensForm form_class = forms.AppRevokeTokensForm
@ -333,7 +341,7 @@ class ApplicationTokenView(mixins.PageIdMixin, LoginRequiredMixin, GetAppsMixin,
return super().form_valid(form) return super().form_valid(form)
class PrivacyPolicyAgreeView(mixins.PageIdMixin, LoginRequiredMixin, FormView): class PrivacyPolicyAgreeView(mixins.PageIdMixin, mixins.MfaRequiredIfConfiguredMixin, FormView):
page_id = "privacy_policy_agree" page_id = "privacy_policy_agree"
template_name = "bid_main/privacy_policy_agree.html" template_name = "bid_main/privacy_policy_agree.html"
form_class = forms.PrivacyPolicyAgreeForm form_class = forms.PrivacyPolicyAgreeForm
@ -374,7 +382,7 @@ class PrivacyPolicyAgreeView(mixins.PageIdMixin, LoginRequiredMixin, FormView):
class DeleteUserView( class DeleteUserView(
mixins.RedirectToPrivacyAgreeMixin, LoginRequiredMixin, GetAppsMixin, FormView mixins.RedirectToPrivacyAgreeMixin, mixins.MfaRequiredIfConfiguredMixin, GetAppsMixin, FormView
): ):
template_name = "bid_main/delete_user.html" template_name = "bid_main/delete_user.html"
form_class = forms.DeleteForm form_class = forms.DeleteForm
@ -420,7 +428,7 @@ class DeleteUserView(
return render(self.request, "bid_main/delete_user/confirm.html", context=ctx) 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" template_name = "bid_main/active_sessions.html"
def get_context_data(self, **kwargs): 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): def post(self, request, *args, **kwargs):
user_session_pk = kwargs.get('pk') user_session_pk = kwargs.get('pk')
if user_session := self.request.user.sessions.filter(pk=user_session_pk).first(): 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") return HttpResponseNotFound("session not found")
class MfaView(LoginRequiredMixin, TemplateView): class MfaView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
template_name = "bid_main/mfa_setup.html" template_name = "bid_main/mfa_setup.html"
def get_context_data(self, **kwargs): 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 import views as auth_views
from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from django.http import HttpResponseBadRequest, JsonResponse 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 django.views.generic import CreateView, TemplateView, FormView, View
from .. import forms, email from .. import forms, email
from . import mixins
from ..models import User from ..models import User
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -91,7 +91,7 @@ class InitialSetPasswordView(auth_views.PasswordResetConfirmView):
form_class = forms.SetInitialPasswordForm form_class = forms.SetInitialPasswordForm
class ConfirmEmailView(LoginRequiredMixin, FormView): class ConfirmEmailView(mixins.MfaRequiredIfConfiguredMixin, FormView):
template_name = "bid_main/confirm_email/start.html" template_name = "bid_main/confirm_email/start.html"
form_class = forms.ConfirmEmailStartForm form_class = forms.ConfirmEmailStartForm
log = logging.getLogger(f"{__name__}.ConfirmEmailView") log = logging.getLogger(f"{__name__}.ConfirmEmailView")
@ -127,7 +127,7 @@ class ConfirmEmailView(LoginRequiredMixin, FormView):
return redirect("bid_main:confirm-email-sent") 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.""" """Cancel the user's email change and redirect to the profile page."""
log = logging.getLogger(f"{__name__}.CancelEmailChangeView") log = logging.getLogger(f"{__name__}.CancelEmailChangeView")
@ -142,11 +142,11 @@ class CancelEmailChangeView(LoginRequiredMixin, View):
return redirect("bid_main:index") return redirect("bid_main:index")
class ConfirmEmailSentView(LoginRequiredMixin, TemplateView): class ConfirmEmailSentView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
template_name = "bid_main/confirm_email/sent.html" 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. """Returns JSON indicating when the email address has last been confirmed.
The timestamp is returned as ISO 8601 to allow future periodic checks The timestamp is returned as ISO 8601 to allow future periodic checks
@ -167,7 +167,7 @@ class ConfirmEmailPollView(LoginRequiredMixin, View):
return JsonResponse({"confirmed": timestamp}) return JsonResponse({"confirmed": timestamp})
class ConfirmEmailVerifiedView(LoginRequiredMixin, TemplateView): class ConfirmEmailVerifiedView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
"""Render explanation on GET, handle confirmation on POST. """Render explanation on GET, handle confirmation on POST.
We only perform the actual database change on a POST, since that protects We only perform the actual database change on a POST, since that protects