Skip to content

Commit

Permalink
feat: Support LoginRequiredMiddleware
Browse files Browse the repository at this point in the history
  • Loading branch information
pennersr committed Sep 13, 2024
1 parent 52a3a68 commit d096a0b
Show file tree
Hide file tree
Showing 25 changed files with 432 additions and 7 deletions.
19 changes: 18 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
project: ['regular', 'headless_only']
project: ['regular', 'headless_only', 'login_required_mw']
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
django-version: ['main', '4.2', '5.0', '5.1']
exclude:
Expand All @@ -34,6 +34,23 @@ jobs:
- python-version: '3.9'
django-version: 'main'

# Only test LoginRequiredMiddleware on latest Python/Django.
- project: 'login_required_mw'
python-version: '3.8'
- project: 'login_required_mw'
python-version: '3.9'
- project: 'login_required_mw'
python-version: '3.10'
- project: 'login_required_mw'
python-version: '3.11'
- project: 'login_required_mw'
django-version: 'main'
- project: 'login_required_mw'
django-version: '4.2'
- project: 'login_required_mw'
django-version: '5.0'


steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
Expand Down
5 changes: 5 additions & 0 deletions .woodpecker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,8 @@ matrix:
PRJ: headless_only
DJANGO_VERSION: djangomain
PYTHON_VERSION: 3.12
# Only test LoginRequiredMiddleware on latest Python/Django.
- STEP: test
PRJ: login_required_mw
DJANGO_VERSION: django51
PYTHON_VERSION: 3.12
3 changes: 3 additions & 0 deletions ChangeLog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
64.3.0 (unreleased)
*******************

- Added transparent support for Django's ``LoginRequiredMiddleware`` (new since
Django 5.1).

- The ``userserssions`` app now emits signals when either the IP address or user
agent for a session changes.

Expand Down
57 changes: 57 additions & 0 deletions allauth/account/internal/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from functools import wraps

from django.contrib.auth import decorators
from django.http import HttpResponseRedirect
from django.urls import reverse

from allauth.account import app_settings
from allauth.account.stages import LoginStageController
from allauth.account.utils import get_login_redirect_url


def _dummy_login_not_required(view_func):
return view_func


login_not_required = getattr(
decorators, "login_not_required", _dummy_login_not_required
)


def login_stage_required(stage: str, redirect_urlname: str):
def decorator(view_func):
@login_not_required
@wraps(view_func)
def _wrapper_view(request, *args, **kwargs):
if request.user.is_authenticated:
return HttpResponseRedirect(get_login_redirect_url(request))
login_stage = LoginStageController.enter(request, stage)
if not login_stage:
return HttpResponseRedirect(reverse(redirect_urlname))
request._login_stage = login_stage
return view_func(request, *args, **kwargs)

return _wrapper_view

return decorator


def unauthenticated_only(function=None):
def decorator(view_func):
@login_not_required
@wraps(view_func)
def _wrapper_view(request, *args, **kwargs):
if (
request.user.is_authenticated
# ACCOUNT_AUTHENTICATED_LOGIN_REDIRECTS = False is going to
# be deprecated.
and app_settings.AUTHENTICATED_LOGIN_REDIRECTS
):
return HttpResponseRedirect(get_login_redirect_url(request))
return view_func(request, *args, **kwargs)

return _wrapper_view

if function:
return decorator(function)
return decorator
2 changes: 2 additions & 0 deletions allauth/account/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest

from allauth.account.internal import flows
from allauth.account.internal.decorators import login_not_required
from allauth.account.middleware import AccountMiddleware
from allauth.core.exceptions import ImmediateHttpResponse

Expand Down Expand Up @@ -42,6 +43,7 @@ def test_remove_dangling_login(
assert (flows.login.LOGIN_SESSION_KEY in request.session) is (not login_removed)


@login_not_required
def raise_immediate_http_response(request):
response = HttpResponse(content="raised-response")
raise ImmediateHttpResponse(response=response)
Expand Down
25 changes: 22 additions & 3 deletions allauth/account/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
UserTokenForm,
)
from allauth.account.internal import flows
from allauth.account.internal.decorators import (
login_not_required,
login_stage_required,
unauthenticated_only,
)
from allauth.account.mixins import (
AjaxCapableProcessFormViewMixin,
CloseableSignupMixin,
Expand Down Expand Up @@ -71,6 +76,7 @@


@method_decorator(rate_limit(action="login"), name="dispatch")
@method_decorator(unauthenticated_only, name="dispatch")
class LoginView(
NextRedirectMixin,
RedirectAuthenticatedUserMixin,
Expand Down Expand Up @@ -137,6 +143,7 @@ def get_context_data(self, **kwargs):


@method_decorator(rate_limit(action="signup"), name="dispatch")
@method_decorator(unauthenticated_only, name="dispatch")
class SignupView(
RedirectAuthenticatedUserMixin,
CloseableSignupMixin,
Expand Down Expand Up @@ -209,6 +216,7 @@ def get_initial(self):
signup = SignupView.as_view()


@method_decorator(login_not_required, name="dispatch")
class ConfirmEmailView(NextRedirectMixin, LogoutFunctionalityMixin, TemplateView):
template_name = "account/email_confirm." + app_settings.TEMPLATE_EXTENSION

Expand Down Expand Up @@ -530,6 +538,7 @@ def get_context_data(self, **kwargs):
password_set = PasswordSetView.as_view()


@method_decorator(login_not_required, name="dispatch")
class PasswordResetView(NextRedirectMixin, AjaxCapableProcessFormViewMixin, FormView):
template_name = "account/password_reset." + app_settings.TEMPLATE_EXTENSION
form_class = ResetPasswordForm
Expand Down Expand Up @@ -570,6 +579,7 @@ class PasswordResetDoneView(TemplateView):


@method_decorator(rate_limit(action="reset_password_from_key"), name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class PasswordResetFromKeyView(
AjaxCapableProcessFormViewMixin,
NextRedirectMixin,
Expand Down Expand Up @@ -661,6 +671,7 @@ def form_valid(self, form):
password_reset_from_key = PasswordResetFromKeyView.as_view()


@method_decorator(login_not_required, name="dispatch")
class PasswordResetFromKeyDoneView(TemplateView):
template_name = (
"account/password_reset_from_key_done." + app_settings.TEMPLATE_EXTENSION
Expand Down Expand Up @@ -699,13 +710,15 @@ def get_redirect_url(self):
logout = LogoutView.as_view()


@method_decorator(login_not_required, name="dispatch")
class AccountInactiveView(TemplateView):
template_name = "account/account_inactive." + app_settings.TEMPLATE_EXTENSION


account_inactive = AccountInactiveView.as_view()


@method_decorator(login_not_required, name="dispatch")
class EmailVerificationSentView(TemplateView):
template_name = "account/verification_sent." + app_settings.TEMPLATE_EXTENSION

Expand Down Expand Up @@ -787,6 +800,7 @@ def form_invalid(self, form):
return HttpResponseRedirect(reverse("account_login"))


@method_decorator(login_not_required, name="dispatch")
def email_verification_sent(request):
if app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED:
return ConfirmEmailVerificationCodeView.as_view()(request)
Expand Down Expand Up @@ -875,6 +889,7 @@ def form_valid(self, form):
reauthenticate = ReauthenticateView.as_view()


@method_decorator(unauthenticated_only, name="dispatch")
class RequestLoginCodeView(RedirectAuthenticatedUserMixin, NextRedirectMixin, FormView):
form_class = RequestLoginCodeForm
template_name = "account/request_login_code." + app_settings.TEMPLATE_EXTENSION
Expand Down Expand Up @@ -907,15 +922,19 @@ def get_context_data(self, **kwargs):
request_login_code = RequestLoginCodeView.as_view()


@method_decorator(
login_stage_required(
stage=LoginByCodeStage.key, redirect_urlname="account_request_login_code"
),
name="dispatch",
)
class ConfirmLoginCodeView(RedirectAuthenticatedUserMixin, NextRedirectMixin, FormView):
form_class = ConfirmLoginCodeForm
template_name = "account/confirm_login_code." + app_settings.TEMPLATE_EXTENSION

@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
self.stage = LoginStageController.enter(request, LoginByCodeStage.key)
if not self.stage:
return HttpResponseRedirect(reverse("account_request_login_code"))
self.stage = request._login_stage
self.user, self.pending_login = flows.login_by_code.get_pending_login(
self.stage.login, peek=True
)
Expand Down
3 changes: 3 additions & 0 deletions allauth/headless/internal/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.middleware.csrf import get_token
from django.views.decorators.csrf import csrf_exempt

from allauth.account.internal.decorators import login_not_required
from allauth.headless.constants import Client
from allauth.headless.internal import authkit

Expand All @@ -17,6 +18,7 @@ def app_view(
function=None,
):
def decorator(view_func):
@login_not_required
@wraps(view_func)
def _wrapper_view(request, *args, **kwargs):
mark_request_as_headless(request, Client.APP)
Expand All @@ -35,6 +37,7 @@ def browser_view(
function=None,
):
def decorator(view_func):
@login_not_required
@wraps(view_func)
def _wrapper_view(request, *args, **kwargs):
mark_request_as_headless(request, Client.BROWSER)
Expand Down
10 changes: 7 additions & 3 deletions allauth/mfa/base/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.views.generic import TemplateView

from allauth.account import app_settings as account_settings
from allauth.account.stages import LoginStageController
from allauth.account.internal.decorators import login_stage_required
from allauth.account.views import BaseReauthenticateView
from allauth.mfa import app_settings
from allauth.mfa.base.forms import AuthenticateForm, ReauthenticateForm
Expand All @@ -17,14 +17,18 @@
from allauth.utils import get_form_class


@method_decorator(
login_stage_required(stage=AuthenticateStage.key, redirect_urlname="account_login"),
name="dispatch",
)
class AuthenticateView(TemplateView):
form_class = AuthenticateForm
webauthn_form_class = AuthenticateWebAuthnForm
template_name = "mfa/authenticate." + account_settings.TEMPLATE_EXTENSION

def dispatch(self, request, *args, **kwargs):
self.stage = LoginStageController.enter(request, AuthenticateStage.key)
if not self.stage or not is_mfa_enabled(
self.stage = request._login_stage
if not is_mfa_enabled(
self.stage.login.user,
[Authenticator.Type.TOTP, Authenticator.Type.WEBAUTHN],
):
Expand Down
2 changes: 2 additions & 0 deletions allauth/mfa/webauthn/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from allauth.account import app_settings as account_settings
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.decorators import reauthentication_required
from allauth.account.internal.decorators import unauthenticated_only
from allauth.account.mixins import NextRedirectMixin
from allauth.account.models import Login
from allauth.account.views import BaseReauthenticateView
Expand Down Expand Up @@ -97,6 +98,7 @@ def form_valid(self, form):
remove_webauthn = RemoveWebAuthnView.as_view()


@method_decorator(unauthenticated_only, name="dispatch")
class LoginWebAuthnView(FormView):
form_class = LoginWebAuthnForm

Expand Down
2 changes: 2 additions & 0 deletions allauth/socialaccount/providers/apple/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.utils.http import urlencode
from django.views.decorators.csrf import csrf_exempt

from allauth.account.internal.decorators import login_not_required
from allauth.socialaccount.internal import jwtkit
from allauth.socialaccount.models import SocialToken
from allauth.socialaccount.providers.oauth2.views import (
Expand Down Expand Up @@ -101,6 +102,7 @@ def get_access_token_data(self, request, app, client, pkce_code_verifier=None):


@csrf_exempt
@login_not_required
def apple_post_callback(request, finish_endpoint_name="apple_finish_callback"):
"""
Apple uses a `form_post` response type, which due to
Expand Down
4 changes: 4 additions & 0 deletions allauth/socialaccount/providers/dummy/views.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import urlencode
from django.views.generic.edit import FormView

from allauth.account.internal.decorators import login_not_required
from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.helpers import (
complete_social_login,
Expand All @@ -15,6 +17,7 @@
from allauth.socialaccount.providers.dummy.provider import DummyProvider


@method_decorator(login_not_required, name="dispatch")
class LoginView(BaseLoginView):
provider_id = DummyProvider.id

Expand All @@ -26,6 +29,7 @@ class AuthenticateView(FormView):
form_class = AuthenticateForm
template_name = "dummy/authenticate_form.html"

@method_decorator(login_not_required)
def dispatch(self, request, *args, **kwargs):
self.state_id = request.GET.get("state")
if not self.state_id:
Expand Down
3 changes: 3 additions & 0 deletions allauth/socialaccount/providers/facebook/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

from django import forms
from django.core.exceptions import PermissionDenied
from django.utils.decorators import method_decorator
from django.views.generic import View

from allauth.account.internal.decorators import login_not_required
from allauth.socialaccount import app_settings
from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.helpers import (
Expand Down Expand Up @@ -53,6 +55,7 @@ def complete_login(self, request, app, access_token, **kwargs):


class LoginByTokenView(View):
@method_decorator(login_not_required)
def dispatch(self, request):
self.adapter = get_adapter()
self.provider = self.adapter.get_provider(request, PROVIDER_ID)
Expand Down
3 changes: 3 additions & 0 deletions allauth/socialaccount/providers/google/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View

from allauth.account.internal.decorators import login_not_required
from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.helpers import (
complete_social_login,
Expand Down Expand Up @@ -120,6 +122,7 @@ def _fetch_user_info(self, access_token):


class LoginByTokenView(View):
@method_decorator(login_not_required)
def dispatch(self, request):
self.adapter = get_adapter()
self.provider = self.adapter.get_provider(
Expand Down
Loading

0 comments on commit d096a0b

Please sign in to comment.