iMerica / dj-rest-auth

Authentication for Django Rest Framework
https://dj-rest-auth.readthedocs.io/en/latest/index.html
MIT License
1.65k stars 306 forks source link

Incorrect Password reset URL when allauth in installed app #440

Open Harold-D opened 1 year ago

Harold-D commented 1 year ago

I have the password reset sequence working using the django.contrib.auth views. Now I would like to be able for the user to initiate a password reset from within my app, so through a REST call, hence dj-rest-auth. Than, I would like the user to use the HTML interface to actually change the password. So only dj-rest-auth view to be able to sent the reset email after a REST call. Than the normal django.contrib HTML views to change the password.

Both REST and web work to the point of sending the email containing the reset URL. However, the link itself is the problem. Using the contrib view / HTML form, the link looks like this: http://127.0.0.1:8000/reset/MQ/bcu19d-e0b431da6abd39711d58cbf89e16df19/ (notice the MQ part). When requesting a password reset as a REST call, the link looks like this: http://127.0.0.1:8000/reset/1/bcu19p-5accea3a393f9e902cfd96467cd6a652/ (MQ replaced by 1, the users primary key)

I have tracked the issue down to the fact that I'm also using allauth because of token authentication. Without allauth in installed apps, the password reset link is correct with REST.

So somehow, dj-rest-auth and allauth are not getting along. How can I make the REST call generate a correct reset URL so I can use the contrib views for the rest of the reset sequence?

Basic out of the box Django 4.1.2 setup

settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'rest_framework',
    'rest_framework.authtoken',

    'allauth',
    'allauth.account',
    'allauth.socialaccount',  # To prevent admin error on user delete

    'dj_rest_auth',
    'dj_rest_auth.registration',
]

urls.py

urlpatterns = [
    # REST view
    path('password/reset/',
         PasswordResetView.as_view(), name='rest_password_reset'),

    # django.contrib
    path('reset_password/',
         auth_views.PasswordResetView.as_view(),
         # template_name="accounts/password_reset.html"),
         name="reset_password"),

    # django.contrib
    path('reset_password_sent/',
         auth_views.PasswordResetDoneView.as_view(),
         # template_name="accounts/password_reset_sent.html"),
         name="password_reset_done"),

    # django.contrib
    path('reset/<uidb64>/<token>/',
         auth_views.PasswordResetConfirmView.as_view(),
         # template_name="accounts/password_reset_form.html"),
         name="password_reset_confirm"),

    # django.contrib
    path('reset_password_complete/',
         auth_views.PasswordResetCompleteView.as_view(),
         # template_name="accounts/password_reset_done.html"),
         name="password_reset_complete"),
]
Harold-D commented 1 year ago

I've tracked it down further to dj_rest's PasswordResetSerializer

    @property
    def password_reset_form_class(self):
        if 'allauth' in settings.INSTALLED_APPS:
            return AllAuthPasswordResetForm
        else:
            return PasswordResetForm

Overridden the serializer, like below, solves the problem.

from django.contrib.auth.forms import PasswordResetForm
from dj_rest_auth.serializers import PasswordResetSerializer

class PasswordResetSerializer(PasswordResetSerializer):
    @property
    def password_reset_form_class(self):
        return PasswordResetForm

Add to setings.py

REST_AUTH_SERIALIZERS = {
    'PASSWORD_RESET_SERIALIZER': '**YOUR_APP_NAME***.serializers.PasswordResetSerializer'
}

Now, although the reset URLs look similar, according to the contrib view, the link is invalid.

Harold-D commented 1 year ago

Allright, find the problem. The dj_rest_auth PasswordResetSerializer save method also has an if statement for allauth to select a token generator. In my case I would like to use the default django.contrib token generator because that is also the one which is going to be used by the view behind the password_reset_confirm url.

So overriding the serializer's safe method like so:

    def save(self):
        from django.contrib.auth.tokens import default_token_generator

        request = self.context.get('request')
        # Set some values to trigger the send_email method.
        opts = {
            'use_https': request.is_secure(),
            'from_email': getattr(settings, 'DEFAULT_FROM_EMAIL'),
            'request': request,
            'token_generator': default_token_generator,
        }

        opts.update(self.get_email_options())
        self.reset_form.save(**opts)

solved the problem.

My question however still remains. What is the purpose of the allauth if statments. Is my use case really this unique? Using DJ_rest to send the password reset email and using the default contrib views to handle the actual password reset?

atmask commented 1 year ago

I have the same issue...It seems to have been introduced by the changes made for: https://github.com/iMerica/dj-rest-auth/pull/276 I am not sure what the purpose of creating the custom form was but the implemented solution does not take into account that the generated url for the reset should be the url of client-side rendered framework routed with something like React Router. The reverse call to get the rest url assumes too much and should be modifiable. The old Django Contrib form used just generate the reset tokens and we could format them into the desired url in the registration/password_reset_email.html template

nathanielarking commented 1 year ago

The reverse call to get the rest url assumes too much and should be modifiable.

You put it well, and I'm having the same issue. Let me know if you come up with a good work around for this. I think we can override allauth's password reset form to insert the client side URL but it doesn't seem possible without some code duplication.

doublo7 commented 1 year ago

The same issue was encountered.

kaiserls commented 1 year ago

I can confirm this issue I also have the problem that when i try to use the uid and token that i get the a "invalid value" response:

curl -X 'POST' \
  'http://0.0.0.0:5005/dj-rest-auth/password/reset/confirm/' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -H 'X-CSRFTOKEN: 9d9pF8KeTDAxrRLMNbQdD14ejFuxYnQLMSa8lmbvzwek0EI0V4m1JodQppvgKRWN' \
  -d '{
  "new_password1": "xxx",
  "new_password2": "xxx",
  "uid": "MQ",
  "token": "bp8bxn-8599feb2adb3f3921c7ee544708d1ab7"
}'
{
  "uid": [
    "Invalid value"
  ]
}
g-normand commented 3 weeks ago

Thanks @Harold-D I had the same problem as @kaiserls The problem was in dj_rest_auth.serializers.PasswordResetConfirmSerializer :

from allauth.account.utils import url_str_to_user_pk as uid_decoder

The solution I found :

from dj_rest_auth.views import PasswordResetConfirmView
re_path(r'^password-reset/confirm/$', PasswordResetConfirmView.as_view(),
            name='password_reset_confirm'),

in settings.py :

REST_AUTH = {
    "PASSWORD_RESET_CONFIRM_SERIALIZER": "accounts.serializers.PasswordResetConfirmSerializerv1",
}

my serializer (I'm forcing " from django.utils.http import urlsafe_base64_decode as uid_decoder")


class PasswordResetConfirmSerializerv1(PasswordResetConfirmSerializer):
    """
    Customize default reset password serializer to change the uid decoder
    """

    def validate(self, attrs):
        from allauth.account.forms import default_token_generator
        from django.utils.http import urlsafe_base64_decode as uid_decoder

        # Decode the uidb64 (allauth use base36) to uid to get User object
        try:
            uid = force_str(uid_decoder(attrs['uid']))
            self.user = User.objects.get(pk=uid)
        except (TypeError, ValueError, OverflowError, User.DoesNotExist):
            raise ValidationError({'uid': [_('Invalid value')]})

        if not default_token_generator.check_token(self.user, attrs['token']):
            raise ValidationError({'token': [_('Invalid value')]})

        self.custom_validation(attrs)
        # Construct SetPasswordForm instance
        self.set_password_form = self.set_password_form_class(
            user=self.user, data=attrs,
        )
        if not self.set_password_form.is_valid():
            raise serializers.ValidationError(self.set_password_form.errors)

        return attrs