Repository: pennersr/django-allauth
Pull request #3727 (2024-04-12 15:34:30)

refactor: Proper ?next= redirect handling throughout
Completes #513 

coveralls: 
[![Coverage Status](https://coveralls.io/builds/66883286/badge)](https://coveralls.io/builds/66883286)

coverage: 95.732% (+0.004%) from 95.728%
when pulling **26832f0109c12a541434c2dbb9105dc5590849c4 on refactor-next-url**
into **fb13bc5f5f0580d146582640f51937b239a9e2b0 on main**.


Patch:
diff --git a/ChangeLog.rst b/ChangeLog.rst
index e084f3ab..36d2e882 100644
--- a/ChangeLog.rst
+++ b/ChangeLog.rst
@@ -8,6 +8,10 @@ Note worthy changes
 
 - Added a new provider, Atlassian
 
+- Next URL handling been streamlined to be consistently applied. Previously, the
+  password reset, change and email confirmation views only supported the
+  ``success_url`` class-level property.
+
 
 Backwards incompatible changes
 ------------------------------
diff --git a/allauth/account/mixins.py b/allauth/account/mixins.py
new file mode 100644
index 00000000..34c17a89
--- /dev/null
+++ b/allauth/account/mixins.py
@@ -0,0 +1,178 @@
+from django.contrib.auth import REDIRECT_FIELD_NAME
+from django.core.exceptions import ImproperlyConfigured
+from django.http import HttpResponsePermanentRedirect, HttpResponseRedirect
+from django.utils.html import format_html
+
+from allauth.account import app_settings
+from allauth.account.adapter import get_adapter
+from allauth.account.internal import flows
+from allauth.account.utils import (
+    get_login_redirect_url,
+    get_next_redirect_url,
+    passthrough_next_redirect_url,
+)
+from allauth.core.exceptions import ImmediateHttpResponse
+from allauth.utils import get_request_param
+
+
+def _ajax_response(request, response, form=None, data=None):
+    adapter = get_adapter()
+    if adapter.is_ajax(request):
+        if isinstance(response, HttpResponseRedirect) or isinstance(
+            response, HttpResponsePermanentRedirect
+        ):
+            redirect_to = response["Location"]
+        else:
+            redirect_to = None
+        response = adapter.ajax_response(
+            request, response, form=form, data=data, redirect_to=redirect_to
+        )
+    return response
+
+
+class RedirectAuthenticatedUserMixin:
+    def dispatch(self, request, *args, **kwargs):
+        if request.user.is_authenticated and app_settings.AUTHENTICATED_LOGIN_REDIRECTS:
+            redirect_to = self.get_authenticated_redirect_url()
+            response = HttpResponseRedirect(redirect_to)
+            return _ajax_response(request, response)
+        else:
+            response = super().dispatch(request, *args, **kwargs)
+        return response
+
+    def get_authenticated_redirect_url(self):
+        redirect_field_name = self.redirect_field_name
+        return get_login_redirect_url(
+            self.request,
+            url=self.get_success_url(),
+            redirect_field_name=redirect_field_name,
+        )
+
+
+class LogoutFunctionalityMixin:
+    def logout(self):
+        flows.logout.logout(self.request)
+
+
+class AjaxCapableProcessFormViewMixin:
+    def get(self, request, *args, **kwargs):
+        response = super().get(request, *args, **kwargs)
+        form = self.get_form()
+        return _ajax_response(
+            self.request, response, form=form, data=self._get_ajax_data_if()
+        )
+
+    def post(self, request, *args, **kwargs):
+        form_class = self.get_form_class()
+        form = self.get_form(form_class)
+        if form.is_valid():
+            response = self.form_valid(form)
+        else:
+            response = self.form_invalid(form)
+        return _ajax_response(
+            self.request, response, form=form, data=self._get_ajax_data_if()
+        )
+
+    def get_form(self, form_class=None):
+        form = getattr(self, "_cached_form", None)
+        if form is None:
+            form = super().get_form(form_class)
+            self._cached_form = form
+        return form
+
+    def _get_ajax_data_if(self):
+        return (
+            self.get_ajax_data()
+            if get_adapter(self.request).is_ajax(self.request)
+            else None
+        )
+
+    def get_ajax_data(self):
+        return None
+
+
+class CloseableSignupMixin:
+    template_name_signup_closed = (
+        "account/signup_closed." + app_settings.TEMPLATE_EXTENSION
+    )
+
+    def dispatch(self, request, *args, **kwargs):
+        try:
+            if not self.is_open():
+                return self.closed()
+        except ImmediateHttpResponse as e:
+            return e.response
+        return super().dispatch(request, *args, **kwargs)
+
+    def is_open(self):
+        return get_adapter(self.request).is_open_for_signup(self.request)
+
+    def closed(self):
+        response_kwargs = {
+            "request": self.request,
+            "template": self.template_name_signup_closed,
+        }
+        return self.response_class(**response_kwargs)
+
+
+class NextRedirectMixin:
+    redirect_field_name = REDIRECT_FIELD_NAME
+
+    def get_context_data(self, **kwargs):
+        ret = super().get_context_data(**kwargs)
+        redirect_field_value = get_request_param(self.request, self.redirect_field_name)
+        ret.update(
+            {
+                "redirect_field_name": self.redirect_field_name,
+                "redirect_field_value": redirect_field_value,
+                "redirect_field": format_html(
+                    '<input type="hidden" name="{}" value="{}">',
+                    self.redirect_field_name,
+                    redirect_field_value,
+                )
+                if redirect_field_value
+                else "",
+            }
+        )
+        return ret
+
+    def get_success_url(self):
+        """
+        We're in a mixin, so we cannot rely on the fact that our super() has a get_success_url.
+        Also, we want to check for -- in this order:
+        1) The `?next=/foo`
+        2) The `get_succes_url()` if available.
+        3) The `.success_url` if available.
+        4) A fallback default success URL: `get_default_success_url()`.
+        """
+        url = self.get_next_url()
+        if url:
+            return url
+
+        if not url:
+            if hasattr(super(), "get_success_url"):
+                try:
+                    url = super().get_success_url()
+                except ImproperlyConfigured:
+                    # Django's default get_success_url() checks self.succes_url,
+                    # and throws this if that is not set. Yet, in our case, we
+                    # want to fallback to the default.
+                    pass
+            elif hasattr(self, "success_url"):
+                url = self.success_url
+                if url:
+                    url = str(url)  # reverse_lazy
+        if not url:
+            url = self.get_default_success_url()
+        return url
+
+    def get_default_success_url(self):
+        return None
+
+    def get_next_url(self):
+        return get_next_redirect_url(self.request, self.redirect_field_name)
+
+    def passthrough_next_url(self, url):
+        return passthrough_next_redirect_url(
+            self.request, url, self.redirect_field_name
+        )
diff --git a/allauth/account/utils.py b/allauth/account/utils.py
index b9c17911..4c29381f 100644
--- a/allauth/account/utils.py
+++ b/allauth/account/utils.py
@@ -4,7 +4,7 @@ from typing import Optional
 
 from django.conf import settings
 from django.contrib import messages
-from django.contrib.auth import get_user_model
+from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model
 from django.core.exceptions import FieldDoesNotExist
 from django.db import models
 from django.db.models import Q
@@ -34,7 +34,7 @@ def _unicode_ci_compare(s1, s2):
     return norm_s1 == norm_s2
 
 
-def get_next_redirect_url(request, redirect_field_name="next"):
+def get_next_redirect_url(request, redirect_field_name=REDIRECT_FIELD_NAME):
     """
     Returns the next URL to redirect to, if it was explicitly passed
     via the request.
@@ -45,7 +45,9 @@ def get_next_redirect_url(request, redirect_field_name="next"):
     return redirect_to
 
 
-def get_login_redirect_url(request, url=None, redirect_field_name="next", signup=False):
+def get_login_redirect_url(
+    request, url=None, redirect_field_name=REDIRECT_FIELD_NAME, signup=False
+):
     ret = url
     if url and callable(url):
         # In order to be able to pass url getters around that depend
diff --git a/allauth/account/views.py b/allauth/account/views.py
index 80225b41..c882ec2c 100644
--- a/allauth/account/views.py
+++ b/allauth/account/views.py
@@ -1,21 +1,15 @@
 from django.contrib import messages
-from django.contrib.auth import REDIRECT_FIELD_NAME
 from django.contrib.auth.decorators import login_required
 from django.contrib.sites.shortcuts import get_current_site
 from django.core.exceptions import PermissionDenied
 from django.core.validators import validate_email
 from django.forms import ValidationError
-from django.http import (
-    Http404,
-    HttpResponse,
-    HttpResponsePermanentRedirect,
-    HttpResponseRedirect,
-)
+from django.http import Http404, HttpResponse, HttpResponseRedirect
 from django.urls import reverse, reverse_lazy
 from django.utils.decorators import method_decorator
 from django.views.decorators.cache import never_cache
 from django.views.decorators.debug import sensitive_post_parameters
-from django.views.generic.base import TemplateResponseMixin, TemplateView, View
+from django.views.generic.base import TemplateView
 from django.views.generic.edit import FormView
 
 from allauth import app_settings as allauth_app_settings
@@ -33,6 +27,14 @@ from allauth.account.forms import (
     UserTokenForm,
 )
 from allauth.account.internal import flows
+from allauth.account.mixins import (
+    AjaxCapableProcessFormViewMixin,
+    CloseableSignupMixin,
+    LogoutFunctionalityMixin,
+    NextRedirectMixin,
+    RedirectAuthenticatedUserMixin,
+    _ajax_response,
+)
 from allauth.account.models import (
     EmailAddress,
     EmailConfirmation,
@@ -41,9 +43,6 @@ from allauth.account.models import (
 from allauth.account.reauthentication import resume_request
 from allauth.account.utils import (
     complete_signup,
-    get_login_redirect_url,
-    get_next_redirect_url,
-    passthrough_next_redirect_url,
     perform_login,
     send_email_confirmation,
     sync_user_email_addresses,
@@ -54,7 +53,7 @@ from allauth.core import ratelimit
 from allauth.core.exceptions import ImmediateHttpResponse
 from allauth.core.internal.httpkit import redirect
 from allauth.decorators import rate_limit
-from allauth.utils import get_form_class, get_request_param
+from allauth.utils import get_form_class
 
 
 INTERNAL_RESET_SESSION_KEY = "_password_reset_key"
@@ -65,101 +64,23 @@ sensitive_post_parameters_m = method_decorator(
 )
 
 
-def _ajax_response(request, response, form=None, data=None):
-    adapter = get_adapter()
-    if adapter.is_ajax(request):
-        if isinstance(response, HttpResponseRedirect) or isinstance(
-            response, HttpResponsePermanentRedirect
-        ):
-            redirect_to = response["Location"]
-        else:
-            redirect_to = None
-        response = adapter.ajax_response(
-            request, response, form=form, data=data, redirect_to=redirect_to
-        )
-    return response
-
-
-class RedirectAuthenticatedUserMixin(object):
-    def dispatch(self, request, *args, **kwargs):
-        if request.user.is_authenticated and app_settings.AUTHENTICATED_LOGIN_REDIRECTS:
-            redirect_to = self.get_authenticated_redirect_url()
-            response = HttpResponseRedirect(redirect_to)
-            return _ajax_response(request, response)
-        else:
-            response = super(RedirectAuthenticatedUserMixin, self).dispatch(
-                request, *args, **kwargs
-            )
-        return response
-
-    def get_authenticated_redirect_url(self):
-        redirect_field_name = self.redirect_field_name
-        return get_login_redirect_url(
-            self.request,
-            url=self.get_success_url(),
-            redirect_field_name=redirect_field_name,
-        )
-
-
-class AjaxCapableProcessFormViewMixin(object):
-    def get(self, request, *args, **kwargs):
-        response = super(AjaxCapableProcessFormViewMixin, self).get(
-            request, *args, **kwargs
-        )
-        form = self.get_form()
-        return _ajax_response(
-            self.request, response, form=form, data=self._get_ajax_data_if()
-        )
-
-    def post(self, request, *args, **kwargs):
-        form_class = self.get_form_class()
-        form = self.get_form(form_class)
-        if form.is_valid():
-            response = self.form_valid(form)
-        else:
-            response = self.form_invalid(form)
-        return _ajax_response(
-            self.request, response, form=form, data=self._get_ajax_data_if()
-        )
-
-    def get_form(self, form_class=None):
-        form = getattr(self, "_cached_form", None)
-        if form is None:
-            form = super(AjaxCapableProcessFormViewMixin, self).get_form(form_class)
-            self._cached_form = form
-        return form
-
-    def _get_ajax_data_if(self):
-        return (
-            self.get_ajax_data()
-            if get_adapter(self.request).is_ajax(self.request)
-            else None
-        )
-
-    def get_ajax_data(self):
-        return None
-
-
-class LogoutFunctionalityMixin(object):
-    def logout(self):
-        flows.logout.logout(self.request)
-
-
 class LoginView(
-    RedirectAuthenticatedUserMixin, AjaxCapableProcessFormViewMixin, FormView
+    NextRedirectMixin,
+    RedirectAuthenticatedUserMixin,
+    AjaxCapableProcessFormViewMixin,
+    FormView,
 ):
     form_class = LoginForm
     template_name = "account/login." + app_settings.TEMPLATE_EXTENSION
     success_url = None
-    redirect_field_name = REDIRECT_FIELD_NAME
 
     @sensitive_post_parameters_m
     @method_decorator(never_cache)
     def dispatch(self, request, *args, **kwargs):
-        return super(LoginView, self).dispatch(request, *args, **kwargs)
+        return super().dispatch(request, *args, **kwargs)
 
     def get_form_kwargs(self):
-        kwargs = super(LoginView, self).get_form_kwargs()
+        kwargs = super().get_form_kwargs()
         kwargs["request"] = self.request
         return kwargs
 
@@ -167,34 +88,21 @@ class LoginView(
         return get_form_class(app_settings.FORMS, "login", self.form_class)
 
     def form_valid(self, form):
-        success_url = self.get_success_url()
+        redirect_url = self.get_success_url()
         try:
-            return form.login(self.request, redirect_url=success_url)
+            return form.login(self.request, redirect_url=redirect_url)
         except ImmediateHttpResponse as e:
             return e.response
 
-    def get_success_url(self):
-        # Explicitly passed ?next= URL takes precedence
-        ret = (
-            get_next_redirect_url(self.request, self.redirect_field_name)
-            or self.success_url
-        )
-        return ret
-
     def get_context_data(self, **kwargs):
         ret = super(LoginView, self).get_context_data(**kwargs)
-        signup_url = passthrough_next_redirect_url(
-            self.request, reverse("account_signup"), self.redirect_field_name
-        )
-        redirect_field_value = get_request_param(self.request, self.redirect_field_name)
+        signup_url = self.passthrough_next_url(reverse("account_signup"))
         site = get_current_site(self.request)
 
         ret.update(
             {
                 "signup_url": signup_url,
                 "site": site,
-                "redirect_field_name": self.redirect_field_name,
-                "redirect_field_value": redirect_field_value,
                 "SOCIALACCOUNT_ENABLED": allauth_app_settings.SOCIALACCOUNT_ENABLED,
             }
         )
@@ -204,74 +112,42 @@ class LoginView(
 login = LoginView.as_view()
 
 
-class CloseableSignupMixin(object):
-    template_name_signup_closed = (
-        "account/signup_closed." + app_settings.TEMPLATE_EXTENSION
-    )
-
-    def dispatch(self, request, *args, **kwargs):
-        try:
-            if not self.is_open():
-                return self.closed()
-        except ImmediateHttpResponse as e:
-            return e.response
-        return super(CloseableSignupMixin, self).dispatch(request, *args, **kwargs)
-
-    def is_open(self):
-        return get_adapter(self.request).is_open_for_signup(self.request)
-
-    def closed(self):
-        response_kwargs = {
-            "request": self.request,
-            "template": self.template_name_signup_closed,
-        }
-        return self.response_class(**response_kwargs)
-
-
 @method_decorator(rate_limit(action="signup"), name="dispatch")
 class SignupView(
     RedirectAuthenticatedUserMixin,
     CloseableSignupMixin,
+    NextRedirectMixin,
     AjaxCapableProcessFormViewMixin,
     FormView,
 ):
     template_name = "account/signup." + app_settings.TEMPLATE_EXTENSION
     form_class = SignupForm
-    redirect_field_name = REDIRECT_FIELD_NAME
-    success_url = None
 
     @sensitive_post_parameters_m
     @method_decorator(never_cache)
     def dispatch(self, request, *args, **kwargs):
-        return super(SignupView, self).dispatch(request, *args, **kwargs)
+        return super().dispatch(request, *args, **kwargs)
 
     def get_form_class(self):
         return get_form_class(app_settings.FORMS, "signup", self.form_class)
 
-    def get_success_url(self):
-        # Explicitly passed ?next= URL takes precedence
-        ret = (
-            get_next_redirect_url(self.request, self.redirect_field_name)
-            or self.success_url
-        )
-        return ret
-
     def form_valid(self, form):
         self.user, resp = form.try_save(self.request)
         if resp:
             return resp
         try:
+            redirect_url = self.get_success_url()
             return complete_signup(
                 self.request,
                 self.user,
                 email_verification=None,
-                success_url=self.get_success_url(),
+                success_url=redirect_url,
             )
         except ImmediateHttpResponse as e:
             return e.response
 
     def get_context_data(self, **kwargs):
-        ret = super(SignupView, self).get_context_data(**kwargs)
+        ret = super().get_context_data(**kwargs)
         form = ret["form"]
         email = self.request.session.get("account_verified_email")
         if email:
@@ -280,17 +156,11 @@ class SignupView(
                 email_keys.append("email2")
             for email_key in email_keys:
                 form.fields[email_key].initial = email
-        login_url = passthrough_next_redirect_url(
-            self.request, reverse("account_login"), self.redirect_field_name
-        )
-        redirect_field_name = self.redirect_field_name
+        login_url = self.passthrough_next_url(reverse("account_login"))
         site = get_current_site(self.request)
-        redirect_field_value = get_request_param(self.request, redirect_field_name)
         ret.update(
             {
                 "login_url": login_url,
-                "redirect_field_name": redirect_field_name,
-                "redirect_field_value": redirect_field_value,
                 "site": site,
                 "SOCIALACCOUNT_ENABLED": allauth_app_settings.SOCIALACCOUNT_ENABLED,
             }
@@ -314,7 +184,7 @@ class SignupView(
 signup = SignupView.as_view()
 
 
-class ConfirmEmailView(TemplateResponseMixin, LogoutFunctionalityMixin, View):
+class ConfirmEmailView(NextRedirectMixin, LogoutFunctionalityMixin, TemplateView):
     template_name = "account/email_confirm." + app_settings.TEMPLATE_EXTENSION
 
     def get(self, *args, **kwargs):
@@ -447,7 +317,7 @@ class ConfirmEmailView(TemplateResponseMixin, LogoutFunctionalityMixin, View):
         return ret
 
     def get_context_data(self, **kwargs):
-        ctx = kwargs
+        ctx = super().get_context_data(**kwargs)
         site = get_current_site(self.request)
         ctx.update(
             {
@@ -462,9 +332,12 @@ class ConfirmEmailView(TemplateResponseMixin, LogoutFunctionalityMixin, View):
         return ctx
 
     def get_redirect_url(self):
-        return get_adapter(self.request).get_email_confirmation_redirect_url(
-            self.request
-        )
+        url = self.get_next_url()
+        if not url:
+            url = get_adapter(self.request).get_email_confirmation_redirect_url(
+                self.request
+            )
+        return url
 
 
 confirm_email = ConfirmEmailView.as_view()
@@ -596,7 +469,7 @@ email = EmailView.as_view()
 
 @method_decorator(login_required, name="dispatch")
 @method_decorator(rate_limit(action="change_password"), name="dispatch")
-class PasswordChangeView(AjaxCapableProcessFormViewMixin, FormView):
+class PasswordChangeView(AjaxCapableProcessFormViewMixin, NextRedirectMixin, FormView):
     template_name = "account/password_change." + app_settings.TEMPLATE_EXTENSION
     form_class = ChangePasswordForm
 
@@ -614,9 +487,7 @@ class PasswordChangeView(AjaxCapableProcessFormViewMixin, FormView):
         kwargs["user"] = self.request.user
         return kwargs
 
-    def get_success_url(self):
-        if self.success_url:
-            return self.success_url
+    def get_default_success_url(self):
         return get_adapter().get_password_change_redirect_url(self.request)
 
     def form_valid(self, form):
@@ -642,7 +513,7 @@ password_change = PasswordChangeView.as_view()
     rate_limit(action="change_password"),
     name="dispatch",
 )
-class PasswordSetView(AjaxCapableProcessFormViewMixin, FormView):
+class PasswordSetView(AjaxCapableProcessFormViewMixin, NextRedirectMixin, FormView):
     template_name = "account/password_set." + app_settings.TEMPLATE_EXTENSION
     form_class = SetPasswordForm
 
@@ -660,9 +531,7 @@ class PasswordSetView(AjaxCapableProcessFormViewMixin, FormView):
         kwargs["user"] = self.request.user
         return kwargs
 
-    def get_success_url(self):
-        if self.success_url:
-            return self.success_url
+    def get_default_success_url(self):
         return get_adapter().get_password_change_redirect_url(self.request)
 
     def form_valid(self, form):
@@ -681,11 +550,10 @@ class PasswordSetView(AjaxCapableProcessFormViewMixin, FormView):
 password_set = PasswordSetView.as_view()
 
 
-class PasswordResetView(AjaxCapableProcessFormViewMixin, FormView):
+class PasswordResetView(NextRedirectMixin, AjaxCapableProcessFormViewMixin, FormView):
     template_name = "account/password_reset." + app_settings.TEMPLATE_EXTENSION
     form_class = ResetPasswordForm
     success_url = reverse_lazy("account_reset_password_done")
-    redirect_field_name = REDIRECT_FIELD_NAME
 
     def get_form_class(self):
         return get_form_class(app_settings.FORMS, "reset_password", self.form_class)
@@ -699,13 +567,11 @@ class PasswordResetView(AjaxCapableProcessFormViewMixin, FormView):
         if r429:
             return r429
         form.save(self.request)
-        return super(PasswordResetView, self).form_valid(form)
+        return super().form_valid(form)
 
     def get_context_data(self, **kwargs):
-        ret = super(PasswordResetView, self).get_context_data(**kwargs)
-        login_url = passthrough_next_redirect_url(
-            self.request, reverse("account_login"), self.redirect_field_name
-        )
+        ret = super().get_context_data(**kwargs)
+        login_url = self.passthrough_next_url(reverse("account_login"))
         # NOTE: For backwards compatibility
         ret["password_reset_form"] = ret.get("form")
         # (end NOTE)
@@ -725,7 +591,10 @@ password_reset_done = PasswordResetDoneView.as_view()
 
 @method_decorator(rate_limit(action="reset_password_from_key"), name="dispatch")
 class PasswordResetFromKeyView(
-    AjaxCapableProcessFormViewMixin, LogoutFunctionalityMixin, FormView
+    AjaxCapableProcessFormViewMixin,
+    NextRedirectMixin,
+    LogoutFunctionalityMixin,
+    FormView,
 ):
     template_name = "account/password_reset_from_key." + app_settings.TEMPLATE_EXTENSION
     form_class = ResetPasswordKeyForm
@@ -763,9 +632,7 @@ class PasswordResetFromKeyView(
                     self.logout()
                     self.request.session[INTERNAL_RESET_SESSION_KEY] = self.key
 
-                return super(PasswordResetFromKeyView, self).dispatch(
-                    request, uidb36, self.key, **kwargs
-                )
+                return super().dispatch(request, uidb36, self.key, **kwargs)
         else:
             token_form = user_token_form_class(data={"uidb36": uidb36, "key": self.key})
             if token_form.is_valid():
@@ -774,7 +641,9 @@ class PasswordResetFromKeyView(
                 # avoids the possibility of leaking the key in the
                 # HTTP Referer header.
                 self.request.session[INTERNAL_RESET_SESSION_KEY] = self.key
-                redirect_url = self.request.path.replace(self.key, self.reset_url_key)
+                redirect_url = self.passthrough_next_url(
+                    self.request.path.replace(self.key, self.reset_url_key)
+                )
                 return redirect(redirect_url)
 
         self.reset_user = None
@@ -821,9 +690,8 @@ class PasswordResetFromKeyDoneView(TemplateView):
 password_reset_from_key_done = PasswordResetFromKeyDoneView.as_view()
 
 
-class LogoutView(TemplateResponseMixin, LogoutFunctionalityMixin, View):
+class LogoutView(NextRedirectMixin, LogoutFunctionalityMixin, TemplateView):
     template_name = "account/logout." + app_settings.TEMPLATE_EXTENSION
-    redirect_field_name = REDIRECT_FIELD_NAME
 
     def get(self, *args, **kwargs):
         if app_settings.LOGOUT_ON_GET:
@@ -842,21 +710,10 @@ class LogoutView(TemplateResponseMixin, LogoutFunctionalityMixin, View):
         response = redirect(url)
         return _ajax_response(self.request, response)
 
-    def get_context_data(self, **kwargs):
-        ctx = kwargs
-        redirect_field_value = get_request_param(self.request, self.redirect_field_name)
-        ctx.update(
-            {
-                "redirect_field_name": self.redirect_field_name,
-                "redirect_field_value": redirect_field_value,
-            }
-        )
-        return ctx
-
     def get_redirect_url(self):
-        return get_next_redirect_url(
-            self.request, self.redirect_field_name
-        ) or get_adapter(self.request).get_logout_redirect_url(self.request)
+        return self.get_next_url() or get_adapter(self.request).get_logout_redirect_url(
+            self.request
+        )
 
 
 logout = LogoutView.as_view()
@@ -876,9 +733,7 @@ class EmailVerificationSentView(TemplateView):
 email_verification_sent = EmailVerificationSentView.as_view()
 
 
-class BaseReauthenticateView(FormView):
-    redirect_field_name = REDIRECT_FIELD_NAME
-
+class BaseReauthenticateView(NextRedirectMixin, FormView):
     def dispatch(self, request, *args, **kwargs):
         resp = self._check_reauthentication_method_available(request)
         if resp:
@@ -903,15 +758,11 @@ class BaseReauthenticateView(FormView):
         if not methods:
             # Reauthentication not available
             raise PermissionDenied("Reauthentication not available")
-        url = passthrough_next_redirect_url(
-            request, methods[0]["url"], self.redirect_field_name
-        )
+        url = self.passthrough_next_url(methods[0]["url"])
         return HttpResponseRedirect(url)
 
-    def get_success_url(self):
-        url = get_next_redirect_url(self.request, self.redirect_field_name)
-        if not url:
-            url = get_adapter(self.request).get_login_redirect_url(self.request)
+    def get_default_success_url(self):
+        url = get_adapter(self.request).get_login_redirect_url(self.request)
         return url
 
     def form_valid(self, form):
@@ -922,11 +773,8 @@ class BaseReauthenticateView(FormView):
 
     def get_context_data(self, **kwargs):
         ret = super().get_context_data(**kwargs)
-        redirect_field_value = get_request_param(self.request, self.redirect_field_name)
         ret.update(
             {
-                "redirect_field_name": self.redirect_field_name,
-                "redirect_field_value": redirect_field_value,
                 "reauthentication_alternatives": self.get_reauthentication_alternatives(),
             }
         )
@@ -939,9 +787,7 @@ class BaseReauthenticateView(FormView):
             alt = dict(method)
             if self.request.path == alt["url"]:
                 continue
-            alt["url"] = passthrough_next_redirect_url(
-                self.request, alt["url"], self.redirect_field_name
-            )
+            alt["url"] = self.passthrough_next_url(alt["url"])
             alts.append(alt)
         alts = sorted(alts, key=lambda alt: alt["description"])
         return alts
diff --git a/allauth/socialaccount/providers/facebook/provider.py b/allauth/socialaccount/providers/facebook/provider.py
index 8f0bb0f5..0a4417ed 100644
--- a/allauth/socialaccount/providers/facebook/provider.py
+++ b/allauth/socialaccount/providers/facebook/provider.py
@@ -3,6 +3,7 @@ import requests
 import string
 from urllib.parse import quote
 
+from django.contrib.auth import REDIRECT_FIELD_NAME
 from django.middleware.csrf import get_token
 from django.template.loader import render_to_string
 from django.urls import reverse
@@ -70,7 +71,7 @@ class FacebookProvider(OAuth2Provider):
     def get_login_url(self, request, **kwargs):
         method = kwargs.pop("method", self.get_method())
         if method == "js_sdk":
-            next = "'%s'" % escapejs(kwargs.get("next") or "")
+            next = "'%s'" % escapejs(kwargs.get(REDIRECT_FIELD_NAME) or "")
             process = "'%s'" % escapejs(kwargs.get("process") or AuthProcess.LOGIN)
             action = "'%s'" % escapejs(kwargs.get("action") or AuthAction.AUTHENTICATE)
             scope = "'%s'" % escapejs(kwargs.get("scope", ""))
diff --git a/allauth/socialaccount/providers/openid/views.py b/allauth/socialaccount/providers/openid/views.py
index f9db6f1a..a1de59e5 100644
--- a/allauth/socialaccount/providers/openid/views.py
+++ b/allauth/socialaccount/providers/openid/views.py
@@ -1,3 +1,4 @@
+from django.contrib.auth import REDIRECT_FIELD_NAME
 from django.http import HttpResponseRedirect
 from django.shortcuts import render
 from django.urls import reverse
@@ -64,7 +65,7 @@ class OpenIDLoginView(View):
         if self.request.method == "GET" and "openid" not in self.request.GET:
             return self.form_class(
                 initial={
-                    "next": self.request.GET.get("next"),
+                    "next": self.request.GET.get(REDIRECT_FIELD_NAME),
                     "process": self.request.GET.get("process"),
                 }
             )
diff --git a/allauth/socialaccount/templatetags/socialaccount.py b/allauth/socialaccount/templatetags/socialaccount.py
index 928544b6..74acc4b7 100644
--- a/allauth/socialaccount/templatetags/socialaccount.py
+++ b/allauth/socialaccount/templatetags/socialaccount.py
@@ -1,4 +1,5 @@
 from django import template
+from django.contrib.auth import REDIRECT_FIELD_NAME
 from django.utils.safestring import mark_safe
 
 from allauth.socialaccount.adapter import get_adapter
@@ -26,15 +27,15 @@ def provider_login_url(context, provider, **params):
         del query["scope"]
     if auth_params == "":
         del query["auth_params"]
-    if "next" not in query:
-        next = get_request_param(request, "next")
+    if REDIRECT_FIELD_NAME not in query:
+        next = get_request_param(request, REDIRECT_FIELD_NAME)
         if next:
-            query["next"] = next
+            query[REDIRECT_FIELD_NAME] = next
         elif process == "redirect":
-            query["next"] = request.get_full_path()
+            query[REDIRECT_FIELD_NAME] = request.get_full_path()
     else:
-        if not query["next"]:
-            del query["next"]
+        if not query[REDIRECT_FIELD_NAME]:
+            del query[REDIRECT_FIELD_NAME]
     # get the login url and append query as url parameters
     return provider.get_login_url(request, **query)
 
diff --git a/allauth/templates/account/email_confirm.html b/allauth/templates/account/email_confirm.html
index b3415742..2020656a 100644
--- a/allauth/templates/account/email_confirm.html
+++ b/allauth/templates/account/email_confirm.html
@@ -19,6 +19,7 @@
             {% element form method="post" action=action_url %}
                 {% slot actions %}
                     {% csrf_token %}
+                    {{ redirect_field }}
                     {% element button type="submit" %}
                         {% trans 'Confirm' %}
                     {% endelement %}
diff --git a/allauth/templates/account/login.html b/allauth/templates/account/login.html
index 765c2fdd..d3519f8b 100644
--- a/allauth/templates/account/login.html
+++ b/allauth/templates/account/login.html
@@ -18,11 +18,7 @@
             {% csrf_token %}
             {% element fields form=form unlabeled=True %}
             {% endelement %}
-            {% if redirect_field_value %}
-                <input type="hidden"
-                       name="{{ redirect_field_name }}"
-                       value="{{ redirect_field_value }}" />
-            {% endif %}
+            {{ redirect_field }}
         {% endslot %}
         {% slot actions %}
             {% element button type="submit" tags="prominent,login" %}
diff --git a/allauth/templates/account/logout.html b/allauth/templates/account/logout.html
index 7c4adfa2..84530a92 100644
--- a/allauth/templates/account/logout.html
+++ b/allauth/templates/account/logout.html
@@ -12,11 +12,7 @@
     {% element form method="post" action=action_url no_visible_fields=True %}
         {% slot body %}
             {% csrf_token %}
-            {% if redirect_field_value %}
-                <input type="hidden"
-                       name="{{ redirect_field_name }}"
-                       value="{{ redirect_field_value }}" />
-            {% endif %}
+            {{ redirect_field }}
         {% endslot %}
         {% slot actions %}
             {% element button type="submit" %}
diff --git a/allauth/templates/account/password_change.html b/allauth/templates/account/password_change.html
index 9df9e262..297dba6f 100644
--- a/allauth/templates/account/password_change.html
+++ b/allauth/templates/account/password_change.html
@@ -11,6 +11,7 @@
     {% element form form=form method="post" action=action_url %}
         {% slot body %}
             {% csrf_token %}
+            {{ redirect_field }}
             {% element fields form=form %}
             {% endelement %}
         {% endslot %}
diff --git a/allauth/templates/account/password_reset_from_key.html b/allauth/templates/account/password_reset_from_key.html
index f8f0d3f9..6c0ef3a5 100644
--- a/allauth/templates/account/password_reset_from_key.html
+++ b/allauth/templates/account/password_reset_from_key.html
@@ -21,6 +21,7 @@
         {% element form method="post" action=action_url %}
             {% slot body %}
                 {% csrf_token %}
+                {{ redirect_field }}
                 {% element fields form=form %}
                 {% endelement %}
             {% endslot %}
diff --git a/allauth/templates/account/password_set.html b/allauth/templates/account/password_set.html
index 21e2fc82..2cb77a42 100644
--- a/allauth/templates/account/password_set.html
+++ b/allauth/templates/account/password_set.html
@@ -12,6 +12,7 @@
     {% element form method="post" action=action_url %}
         {% slot body %}
             {% csrf_token %}
+            {{ redirect_field }}
             {% element fields form=form %}
             {% endelement %}
         {% endslot %}
diff --git a/allauth/templates/account/reauthenticate.html b/allauth/templates/account/reauthenticate.html
index 38c22def..6d7fff5d 100644
--- a/allauth/templates/account/reauthenticate.html
+++ b/allauth/templates/account/reauthenticate.html
@@ -9,11 +9,7 @@
             {% csrf_token %}
             {% element fields form=form unlabeled=True %}
             {% endelement %}
-            {% if redirect_field_value %}
-                <input type="hidden"
-                       name="{{ redirect_field_name }}"
-                       value="{{ redirect_field_value }}" />
-            {% endif %}
+            {{ redirect_field }}
         {% endslot %}
         {% slot actions %}
             {% element button type="submit" %}
diff --git a/allauth/templates/account/signup.html b/allauth/templates/account/signup.html
index d55fb146..d59c2553 100644
--- a/allauth/templates/account/signup.html
+++ b/allauth/templates/account/signup.html
@@ -16,11 +16,7 @@
             {% csrf_token %}
             {% element fields form=form unlabeled=True %}
             {% endelement %}
-            {% if redirect_field_value %}
-                <input type="hidden"
-                       name="{{ redirect_field_name }}"
-                       value="{{ redirect_field_value }}" />
-            {% endif %}
+            {{ redirect_field }}
         {% endslot %}
         {% slot actions %}
             {% element button tags="prominent,signup" type="submit" %}
diff --git a/allauth/templates/mfa/reauthenticate.html b/allauth/templates/mfa/reauthenticate.html
index 749af87a..eca1dfe3 100644
--- a/allauth/templates/mfa/reauthenticate.html
+++ b/allauth/templates/mfa/reauthenticate.html
@@ -9,11 +9,7 @@
             {% csrf_token %}
             {% element fields form=form unlabeled=True %}
             {% endelement %}
-            {% if redirect_field_value %}
-                <input type="hidden"
-                       name="{{ redirect_field_name }}"
-                       value="{{ redirect_field_value }}" />
-            {% endif %}
+            {{ redirect_field }}
         {% endslot %}
         {% slot actions %}
             {% element button type="submit" tags="primary,mfa,login" %}
diff --git a/allauth/templates/socialaccount/signup.html b/allauth/templates/socialaccount/signup.html
index a361ec37..cfcdfa88 100644
--- a/allauth/templates/socialaccount/signup.html
+++ b/allauth/templates/socialaccount/signup.html
@@ -18,11 +18,7 @@
             {% csrf_token %}
             {% element fields form=form unlabeled=True %}
             {% endelement %}
-            {% if redirect_field_value %}
-                <input type="hidden"
-                       name="{{ redirect_field_name }}"
-                       value="{{ redirect_field_value }}" />
-            {% endif %}
+            {{ redirect_field }}
         {% endslot %}
         {% slot actions %}
             {% element button type="submit" %}


Test patch:
diff --git a/allauth/account/tests/test_change_password.py b/allauth/account/tests/test_change_password.py
index acc95f53..81bbc75c 100644
--- a/allauth/account/tests/test_change_password.py
+++ b/allauth/account/tests/test_change_password.py
@@ -19,16 +19,13 @@ def test_set_usable_password_redirects_to_change(auth_client, user):
 
 
 @pytest.mark.parametrize(
-    "logout,redirect_chain",
+    "logout,next_url,redirect_chain",
     [
-        (
-            False,
-            [
-                (reverse("account_change_password"), 302),
-            ],
-        ),
+        (False, "", [(reverse("account_change_password"), 302)]),
+        (False, "/foo", [("/foo", 302)]),
         (
             True,
+            "",
             [
                 (reverse("account_change_password"), 302),
                 (
@@ -37,33 +34,36 @@ def test_set_usable_password_redirects_to_change(auth_client, user):
                 ),
             ],
         ),
+        (True, "/foo", [("/foo", 302)]),
     ],
 )
-def test_set_password(client, user, password_factory, logout, settings, redirect_chain):
+def test_set_password(
+    client, user, next_url, password_factory, logout, settings, redirect_chain
+):
     settings.ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = logout
     user.set_unusable_password()
     user.save()
     client.force_login(user)
     password = password_factory()
+    data = {"password1": password, "password2": password}
+    if next_url:
+        data["next"] = next_url
     resp = client.post(
         reverse("account_set_password"),
-        {"password1": password, "password2": password},
+        data,
         follow=True,
     )
     assert resp.redirect_chain == redirect_chain
 
 
 @pytest.mark.parametrize(
-    "logout,redirect_chain",
+    "logout,next_url,redirect_chain",
     [
-        (
-            False,
-            [
-                (reverse("account_change_password"), 302),
-            ],
-        ),
+        (False, "", [(reverse("account_change_password"), 302)]),
+        (False, "/foo", [("/foo", 302)]),
         (
             True,
+            "",
             [
                 (reverse("account_change_password"), 302),
                 (
@@ -72,12 +72,14 @@ def test_set_password(client, user, password_factory, logout, settings, redirect
                 ),
             ],
         ),
+        (True, "/foo", [("/foo", 302)]),
     ],
 )
 def test_change_password(
     auth_client,
     user,
     user_password,
+    next_url,
     password_factory,
     logout,
     settings,
@@ -87,9 +89,12 @@ def test_change_password(
     settings.ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = logout
     settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
     password = password_factory()
+    data = {"oldpassword": user_password, "password1": password, "password2": password}
+    if next_url:
+        data["next"] = next_url
     resp = auth_client.post(
         reverse("account_change_password"),
-        {"oldpassword": user_password, "password1": password, "password2": password},
+        data,
         follow=True,
     )
     assert resp.redirect_chain == redirect_chain
diff --git a/allauth/account/tests/test_confirm_email.py b/allauth/account/tests/test_confirm_email.py
index 56612541..25fb1c3d 100644
--- a/allauth/account/tests/test_confirm_email.py
+++ b/allauth/account/tests/test_confirm_email.py
@@ -8,6 +8,7 @@ from django.http import HttpResponseRedirect
 from django.urls import reverse
 from django.utils.timezone import now
 
+import pytest
 from pytest_django.asserts import (
     assertRedirects,
     assertTemplateNotUsed,
@@ -26,7 +27,14 @@ from allauth.account.utils import user_pk_to_url_str
 from .test_models import UUIDUser
 
 
-def test_login_on_confirm(user_factory, client):
+@pytest.mark.parametrize(
+    "query,expected_location",
+    [
+        ("", settings.LOGIN_REDIRECT_URL),
+        ("?next=/foo", "/foo"),
+    ],
+)
+def test_login_on_confirm(user_factory, client, query, expected_location):
     settings.ACCOUNT_EMAIL_CONFIRMATION_HMAC = True
     settings.ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True
     user = user_factory(email_verified=False)
@@ -41,7 +49,8 @@ def test_login_on_confirm(user_factory, client):
     session["account_user"] = user_pk_to_url_str(user)
     session.save()
 
-    resp = client.post(reverse("account_confirm_email", args=[key]))
+    resp = client.post(reverse("account_confirm_email", args=[key]) + query)
+    assert resp["location"] == expected_location
     email = EmailAddress.objects.get(pk=email.pk)
     assert email.verified
 
diff --git a/allauth/account/tests/test_reset_password.py b/allauth/account/tests/test_reset_password.py
index e9d62724..e94f7faa 100644
--- a/allauth/account/tests/test_reset_password.py
+++ b/allauth/account/tests/test_reset_password.py
@@ -5,15 +5,30 @@ from django.contrib.auth.models import AnonymousUser
 from django.core import mail
 from django.test.utils import override_settings
 from django.urls import reverse
+from django.utils.http import urlencode
 
 import pytest
+from pytest_django.asserts import assertRedirects, assertTemplateUsed
 
 from allauth.account import app_settings
-from allauth.account.forms import ResetPasswordForm
+from allauth.account.forms import ResetPasswordForm, default_token_generator
 from allauth.account.models import EmailAddress
+from allauth.account.utils import user_pk_to_url_str
 from allauth.tests import TestCase
 
 
+@pytest.fixture
+def password_reset_url():
+    def f(user):
+        temp_key = default_token_generator.make_token(user)
+        uid = user_pk_to_url_str(user)
+        return reverse(
+            "account_reset_password_from_key", kwargs={"uidb36": uid, "key": temp_key}
+        )
+
+    return f
+
+
 @pytest.mark.django_db
 def test_reset_password_unknown_account(client, settings):
     settings.ACCOUNT_PREVENT_ENUMERATION = True
@@ -36,6 +51,18 @@ def test_reset_password_unknown_account_disabled(client, settings):
     assert len(mail.outbox) == 0
 
 
+@pytest.mark.parametrize(
+    "query,expected_location",
+    [("", reverse("account_reset_password_done")), ("?next=/foo", "/foo")],
+)
+def test_reset_password_next_url(client, user, query, expected_location):
+    resp = client.post(
+        reverse("account_reset_password") + query,
+        data={"email": user.email},
+    )
+    assert resp["location"] == expected_location
+
+
 @override_settings(
     ACCOUNT_PREVENT_ENUMERATION=False,
     ACCOUNT_DEFAULT_HTTP_PROTOCOL="https",
@@ -133,72 +160,6 @@ class ResetPasswordTests(TestCase):
 
         self.assertTrue(resp.context_data["token_fail"])
 
-    def test_password_reset_flow(self):
-        """
-        Tests the password reset flow: requesting a new password,
-        receiving the reset link via email and finally resetting the
-        password to a new value.
-        """
-        # Request new password
-        user = self._request_new_password()
-        body = mail.outbox[0].body
-        self.assertGreater(body.find("https://"), 0)
-
-        # Extract URL for `password_reset_from_key` view and access it
-        url = body[body.find("/password/reset/") :].split()[0]
-        resp = self.client.get(url)
-        # Follow the redirect the actual password reset page with the key
-        # hidden.
-        url = resp.url
-        resp = self.client.get(url)
-        self.assertTemplateUsed(
-            resp,
-            "account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION,
-        )
-        self.assertFalse("token_fail" in resp.context_data)
-
-        # Reset the password
-        resp = self.client.post(
-            url, {"password1": "newpass123", "password2": "newpass123"}
-        )
-        self.assertRedirects(resp, reverse("account_reset_password_from_key_done"))
-        assert "Your password has been reset" in mail.outbox[-1].body
-
-        # Check the new password is in effect
-        user = get_user_model().objects.get(pk=user.pk)
-        self.assertTrue(user.check_password("newpass123"))
-
-        # Trying to reset the password against the same URL (or any other
-        # invalid/obsolete URL) returns a bad token response
-        resp = self.client.post(
-            url, {"password1": "newpass123", "password2": "newpass123"}
-        )
-        self.assertTemplateUsed(
-            resp,
-            "account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION,
-        )
-        self.assertTrue(resp.context_data["token_fail"])
-
-        # Same should happen when accessing the page directly
-        response = self.client.get(url)
-        self.assertTemplateUsed(
-            response,
-            "account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION,
-        )
-        self.assertTrue(response.context_data["token_fail"])
-
-        # When in XHR views, it should respond with a 400 bad request
-        # code, and the response body should contain the JSON-encoded
-        # error from the adapter
-        response = self.client.post(
-            url,
-            {"password1": "newpass123", "password2": "newpass123"},
-            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
-        )
-        self.assertEqual(response.status_code, 400)
-        data = json.loads(response.content.decode("utf8"))
-        assert "invalid" in data["form"]["errors"][0]
-
     @override_settings(
         ACCOUNT_AUTHENTICATION_METHOD=app_settings.AuthenticationMethod.EMAIL
     )
@@ -296,3 +257,103 @@ class ResetPasswordTests(TestCase):
         user = self._create_user(password=password)
         self.client.force_login(user)
         return user
+
+
+def test_password_reset_flow(client, user, mailoutbox, settings):
+    """
+    Tests the password reset flow: requesting a new password,
+    receiving the reset link via email and finally resetting the
+    password to a new value.
+    """
+    settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
+
+    # Request new password
+    client.post(
+        reverse("account_reset_password"),
+        data={"email": user.email},
+    )
+    assert len(mail.outbox) == 1
+    assert mailoutbox[0].to == [user.email]
+    body = mailoutbox[0].body
+    assert body.find("http://") > 0
+
+    # Extract URL for `password_reset_from_key` view and access it
+    url = body[body.find("/password/reset/") :].split()[0]
+    resp = client.get(url)
+    # Follow the redirect the actual password reset page with the key
+    # hidden.
+    url = resp.url
+    resp = client.get(url)
+    assertTemplateUsed(
+        resp,
+        "account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION,
+    )
+    assert "token_fail" not in resp.context_data
+
+    # Reset the password
+    resp = client.post(url, {"password1": "newpass123", "password2": "newpass123"})
+    assertRedirects(resp, reverse("account_reset_password_from_key_done"))
+    assert "Your password has been reset" in mailoutbox[-1].body
+
+    # Check the new password is in effect
+    user = get_user_model().objects.get(pk=user.pk)
+    assert user.check_password("newpass123")
+
+    # Trying to reset the password against the same URL (or any other
+    # invalid/obsolete URL) returns a bad token response
+    resp = client.post(url, {"password1": "newpass123", "password2": "newpass123"})
+    assertTemplateUsed(
+        resp,
+        "account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION,
+    )
+    assert resp.context_data["token_fail"]
+
+    # Same should happen when accessing the page directly
+    response = client.get(url)
+    assertTemplateUsed(
+        response,
+        "account/password_reset_from_key.%s" % app_settings.TEMPLATE_EXTENSION,
+    )
+    assert response.context_data["token_fail"]
+
+    # When in XHR views, it should respond with a 400 bad request
+    # code, and the response body should contain the JSON-encoded
+    # error from the adapter
+    response = client.post(
+        url,
+        {"password1": "newpass123", "password2": "newpass123"},
+        HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+    )
+    assert response.status_code == 400
+    data = json.loads(response.content.decode("utf8"))
+    assert "invalid" in data["form"]["errors"][0]
+
+
+@pytest.mark.parametrize(
+    "next_url,expected_location",
+    [(None, reverse("account_reset_password_from_key_done")), ("/foo", "/foo")],
+)
+def test_reset_password_from_key_next_url(
+    user, client, password_factory, next_url, expected_location, password_reset_url
+):
+    url = password_reset_url(user)
+    query = ""
+    if next_url:
+        query = "?" + urlencode({"next": next_url})
+    resp = client.get(url + query)
+    assert resp.status_code == 302
+    assert (
+        resp["location"]
+        == reverse(
+            "account_reset_password_from_key",
+            kwargs={"uidb36": user_pk_to_url_str(user), "key": "set-password"},
+        )
+        + query
+    )
+    password = password_factory()
+    data = {"password1": password, "password2": password}
+    if next_url:
+        data["next"] = next_url
+    resp = client.post(resp["location"], data)
+    assert resp.status_code == 302
+    assert resp["location"] == expected_location