Repository: pennersr/django-allauth

Issue:
Reset password from key endpoint response when token fails. 
When you call reset password endpoint using ajax, the normal flow works fine but if you send an used token, the response is a HTML template saying the token is invalid and its status is 200. 

Response should be json dict with an error and status 403 or 400. 



Discussion:
I can confirm this, I've hit it myself.

I understand returning a 400 response for non-XHR requests might be disruptive if sites don't implement/customize 400 error pages, so taking that into account I believe this would need to be special-cased for XHR requests, @pennersr?

I haven't investigated this myself yet, but it looks as if the adapter method `ajax_response` is not reached in this scenario. Also see `_ajax_response` in `account.views`. Normally these methods already take care of the special casing, so what needs to be investigated is why this is not happening here ...

@pennersr it doesn't reach `_ajax_response()` because the view overrides `dispatch()`, aborting early on with `_response_bad_token()`. At the same time, `AjaxCapableProcessFormViewMixin` only deals with `post()`.


Resolving patch:
diff --git a/allauth/account/forms.py b/allauth/account/forms.py
--- a/allauth/account/forms.py
+++ b/allauth/account/forms.py
@@ -17,8 +17,9 @@
                      get_username_max_length)
 
 from .models import EmailAddress
-from .utils import (perform_login, setup_user_email, user_username,
-                    user_pk_to_url_str, filter_users_by_email)
+from .utils import (perform_login, setup_user_email, url_str_to_user_pk,
+                    user_username, user_pk_to_url_str, filter_users_by_email,
+                    get_user_model)
 from .app_settings import AuthenticationMethod
 from . import app_settings
 from .adapter import get_adapter
@@ -464,3 +465,37 @@ def clean_password2(self):
 
     def save(self):
         get_adapter().set_password(self.user, self.cleaned_data["password1"])
+
+
+class UserTokenForm(forms.Form):
+
+    uidb36 = forms.CharField()
+    key = forms.CharField()
+
+    reset_user = None
+    token_generator = default_token_generator
+
+    error_messages = {
+        'token_invalid': _('The password reset token was invalid.'),
+    }
+
+    def _get_user(self, uidb36):
+        User = get_user_model()
+        try:
+            pk = url_str_to_user_pk(uidb36)
+            return User.objects.get(pk=pk)
+        except (ValueError, User.DoesNotExist):
+            return None
+
+    def clean(self):
+        cleaned_data = super(UserTokenForm, self).clean()
+
+        uidb36 = cleaned_data['uidb36']
+        key = cleaned_data['key']
+
+        self.reset_user = self._get_user(uidb36)
+        if (self.reset_user is None or
+            not self.token_generator.check_token(self.reset_user, key)):
+            raise forms.ValidationError(self.error_messages['token_invalid'])
+
+        return cleaned_data
diff --git a/allauth/account/views.py b/allauth/account/views.py
--- a/allauth/account/views.py
+++ b/allauth/account/views.py
@@ -2,27 +2,24 @@
 from django.contrib.sites.models import Site
 from django.http import (HttpResponseRedirect, Http404,
                          HttpResponsePermanentRedirect)
-from django.shortcuts import get_object_or_404
 from django.views.generic.base import TemplateResponseMixin, View, TemplateView
 from django.views.generic.edit import FormView
 from django.contrib import messages
 from django.contrib.auth.decorators import login_required
 from django.contrib.auth import logout as auth_logout
-from django.contrib.auth.tokens import default_token_generator
 from django.shortcuts import redirect
 from django.views.decorators.debug import sensitive_post_parameters
 from django.utils.decorators import method_decorator
 
 from ..exceptions import ImmediateHttpResponse
-from ..utils import get_user_model, get_form_class, get_request_param
+from ..utils import get_form_class, get_request_param
 
 from .utils import (get_next_redirect_url, complete_signup,
                     get_login_redirect_url, perform_login,
-                    passthrough_next_redirect_url,
-                    url_str_to_user_pk)
+                    passthrough_next_redirect_url)
 from .forms import AddEmailForm, ChangePasswordForm
 from .forms import LoginForm, ResetPasswordKeyForm
-from .forms import ResetPasswordForm, SetPasswordForm, SignupForm
+from .forms import ResetPasswordForm, SetPasswordForm, SignupForm, UserTokenForm
 from .utils import sync_user_email_addresses
 from .models import EmailAddress, EmailConfirmation
 
@@ -557,7 +554,6 @@ class PasswordResetDoneView(TemplateView):
 class PasswordResetFromKeyView(AjaxCapableProcessFormViewMixin, FormView):
     template_name = "account/password_reset_from_key.html"
     form_class = ResetPasswordKeyForm
-    token_generator = default_token_generator
     success_url = reverse_lazy("account_reset_password_from_key_done")
 
     def get_form_class(self):
@@ -565,21 +561,20 @@ def get_form_class(self):
                               'reset_password_from_key',
                               self.form_class)
 
-    def _get_user(self, uidb36):
-        try:
-            pk = url_str_to_user_pk(uidb36)
-        except ValueError:
-            raise Http404
-        return get_object_or_404(get_user_model(), pk=pk)
-
     def dispatch(self, request, uidb36, key, **kwargs):
         self.request = request
-        self.uidb36 = uidb36
         self.key = key
-        self.reset_user = self._get_user(uidb36)
-        if not self.token_generator.check_token(self.reset_user, key):
-            return self._response_bad_token(request, uidb36, key, **kwargs)
+
+        # (Ab)using forms here to be able to handle errors in XHR #890
+        token_form = UserTokenForm(data={'uidb36': uidb36, 'key': key})
+
+        if not token_form.is_valid():
+            response = self.render_to_response(
+                self.get_context_data(token_fail=True)
+            )
+            return _ajax_response(self.request, response, form=token_form)
         else:
+            self.reset_user = token_form.reset_user
             return super(PasswordResetFromKeyView, self).dispatch(request,
                                                                   uidb36,
                                                                   key,
@@ -601,9 +596,6 @@ def form_valid(self, form):
                                     user=self.reset_user)
         return super(PasswordResetFromKeyView, self).form_valid(form)
 
-    def _response_bad_token(self, request, uidb36, key, **kwargs):
-        return self.render_to_response(self.get_context_data(token_fail=True))
-
 password_reset_from_key = PasswordResetFromKeyView.as_view()
 
 


Test patch:
diff --git a/allauth/account/tests.py b/allauth/account/tests.py
--- a/allauth/account/tests.py
+++ b/allauth/account/tests.py
@@ -216,6 +216,23 @@ def test_password_reset_flow(self):
         self.assertTemplateUsed(resp, 'account/password_reset_from_key.html')
         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.html')
+        self.assertEqual(response.context_data['token_fail'], True)
+
+        # 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'))
+        self.assertTrue('form_errors' in data)
+        self.assertTrue('__all__' in data['form_errors'])
+
     def test_email_verification_mandatory(self):
         c = Client()
         # Signup