Django-allauth: Password reset link result bad token in mobile environment (iOS)

Created on 6 Feb 2019  路  2Comments  路  Source: pennersr/django-allauth

Switching between mobile email app and browser app is pretty common scenario in mobile environment. After follow password reset url django-allauth return bad token text.
This is due to url was accessed from mail app.

iOS 12.1.3 , latest Gmail and Chrome
django-allauth==0.38.0

How to reproduce:

  1. Submit 'password reset with key' form
  2. Go to Gmail app and click on provided url
  3. Google Chrome opens with allauth's bad token page

Expected result:

User will be shown password reset form (which will be shown if you copy and paste provided url )

Most helpful comment

Here is how the current system works:

  1. You get a password reset link with a unique key in the email.
  2. You open the link, it calls PasswordResetFromKeyView that saves this key into your session and redirects back to the same page, but with a fake static key. (I can go on a long rant that this is a useless exercise since the original URL was exposed and this adds no extra security, but we will skip it.)
  3. The same PasswordResetFromKeyView fires again and it can already reset your password.

What happens on iOS, in particular in Gmail's app is that Gmail follows the link for you to its final URL in its own "internal browser" and then opens only the destination URL in the actual external browser. The problem here is that the session is now saved in your Gmail's browser, and the actual browser that you intend to use only sees this fake key and an empty session. Hence the library says that it cannot reset anything, because the token is bad.

To fix this, you can manually overwrite this view, get rid of this redirect dance, and just reset a password via the original URL with password reset key in its GET parameter.

In your views.py, add this overwritten method:

from allauth.account.forms import UserTokenForm
from allauth.account.views import PasswordResetFromKeyView as AllauthPasswordResetFromKeyView
from allauth.account.views import _ajax_response
from django.views.generic.edit import FormView

class PasswordResetFromKeyView(AllauthPasswordResetFromKeyView):

    def dispatch(self, request, uidb36, key, **kwargs):
        self.request = request
        self.key = key
        token_form = UserTokenForm(
            data={'uidb36': uidb36, 'key': self.key})
        if token_form.is_valid():
            # Store the key in the session and redirect to the
            # password reset form at a URL without the key. That
            # avoids the possibility of leaking the key in the
            # HTTP Referer header.
            # (Ab)using forms here to be able to handle errors in XHR #890
            token_form = UserTokenForm(
                data={'uidb36': uidb36, 'key': self.key})
            if token_form.is_valid():
                self.reset_user = token_form.reset_user
                return super(FormView, self).dispatch(request, uidb36, self.key, **kwargs)
        self.reset_user = None
        response = self.render_to_response(
            self.get_context_data(token_fail=True)
        )
        return _ajax_response(self.request, response, form=token_form)

Then in your urls.py add this view before allauth's urls like so:

from django.urls import include, path, url
from project.views import PasswordResetFromKeyView

urlpatterns = [
    url(r"^password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", PasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"),
    url(include('allauth.urls')),
]

All 2 comments

Here is how the current system works:

  1. You get a password reset link with a unique key in the email.
  2. You open the link, it calls PasswordResetFromKeyView that saves this key into your session and redirects back to the same page, but with a fake static key. (I can go on a long rant that this is a useless exercise since the original URL was exposed and this adds no extra security, but we will skip it.)
  3. The same PasswordResetFromKeyView fires again and it can already reset your password.

What happens on iOS, in particular in Gmail's app is that Gmail follows the link for you to its final URL in its own "internal browser" and then opens only the destination URL in the actual external browser. The problem here is that the session is now saved in your Gmail's browser, and the actual browser that you intend to use only sees this fake key and an empty session. Hence the library says that it cannot reset anything, because the token is bad.

To fix this, you can manually overwrite this view, get rid of this redirect dance, and just reset a password via the original URL with password reset key in its GET parameter.

In your views.py, add this overwritten method:

from allauth.account.forms import UserTokenForm
from allauth.account.views import PasswordResetFromKeyView as AllauthPasswordResetFromKeyView
from allauth.account.views import _ajax_response
from django.views.generic.edit import FormView

class PasswordResetFromKeyView(AllauthPasswordResetFromKeyView):

    def dispatch(self, request, uidb36, key, **kwargs):
        self.request = request
        self.key = key
        token_form = UserTokenForm(
            data={'uidb36': uidb36, 'key': self.key})
        if token_form.is_valid():
            # Store the key in the session and redirect to the
            # password reset form at a URL without the key. That
            # avoids the possibility of leaking the key in the
            # HTTP Referer header.
            # (Ab)using forms here to be able to handle errors in XHR #890
            token_form = UserTokenForm(
                data={'uidb36': uidb36, 'key': self.key})
            if token_form.is_valid():
                self.reset_user = token_form.reset_user
                return super(FormView, self).dispatch(request, uidb36, self.key, **kwargs)
        self.reset_user = None
        response = self.render_to_response(
            self.get_context_data(token_fail=True)
        )
        return _ajax_response(self.request, response, form=token_form)

Then in your urls.py add this view before allauth's urls like so:

from django.urls import include, path, url
from project.views import PasswordResetFromKeyView

urlpatterns = [
    url(r"^password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", PasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"),
    url(include('allauth.urls')),
]

@Danilka thank you for your input. I'm not an experienced programmer, can you please explain why are you validating the token_form twice with the same data?!

Was this page helpful?
0 / 5 - 0 ratings