sunscrapers / djoser

REST implementation of Django authentication system.
MIT License
2.54k stars 458 forks source link

Route for checking password reset uid and token #522

Open bodgerbarnett opened 4 years ago

bodgerbarnett commented 4 years ago

Am I right in saying that there is no route that can be used to check a password reset UID and token before the user tries to reset their password?

What I'd like is, if a user goes to a URL that contains an invalid (or expired) token, the page tells them that the URL is invalid. At the moment, that information is only available once the user tries to reset their password and gets the 400 Bad Request error from the POST.

If there was a "check this UID and token" route, I could post to that on page load and let the user know if the token was invalid before they try and submit the form.

tomwojcik commented 4 years ago

That is correct.

Adding a get method here might be tempting but I'd advise against this

https://github.com/sunscrapers/djoser/blob/6bba95a89875a53d90f4d1f7060ab0edd97d27f8/djoser/views.py#L246-L260

as you shouldn't really pass any sensitive data via url. Instead, pass them as a payload so post ing would be better. Sounds like a new action to me.

As it's not a regular use case it might result in some security issues though I can't come up with any.

Therefore, I don't think we will ever implement the action you're asking about.

Have in mind that for token creation we use

from django.contrib.auth.tokens import default_token_generator

which has a check_token method.

bodgerbarnett commented 4 years ago

Sorry, I don't think I understand.

I have PASSWORD_RESET_CONFIRM_URL set as "password/reset/confirm/{uid}/{token}" so, when the user asks for a password reset, they enter their email address and a link like:-

http://example.com/password/reset/confirm/{uid}/{token}

is sent to them. They click this link and my site does a GET request to ask them what they want to set their password to. My app will then POST the new password and the captured uid and token (from the url) to the confirm route.

The issue I have is that, for example, if the user then clicks on that link in the email again, the page they see won't tell them that the token in it is now no longer valid - it'll just allow them to set a new password and only fail when the POST fails.

What I'm suggesting is that when the user clicks on the (now invalid) link in the email, the token is checked at that point and the web page can say "this is an invalid password reset link".

This is how django-allauth does it - it makes sense to me.

tomwojcik commented 4 years ago

Sure, I'm open to discussion.

So when user clicks http://example.com/password/reset/confirm/{uid}/{token} , this link is opened on the frontend and at this point, there are no requests that are being made to the backend, right? Only after the user writes password etc it's then POSTed with new credentials and those tokens.

And what you want is to first do an additional GET (or POST) request to check the token validity because it doesn't make sense from the UX perspective to allow the user to try to do something he actually can't do, right? Makes sense.

Please show me how django-allauth does this.

bodgerbarnett commented 4 years ago

Yup, I think that's it.

I've just checked allauth and they seem to check the validity of the key, store it in the session, then redirect to another URL in order to hide the key. Not sure why but the code for it is at https://github.com/pennersr/django-allauth/blob/master/allauth/account/views.py#L682.

tomwojcik commented 4 years ago

I keep thinking about it and I can't decide whether it's a good idea or not.

It doesn't feel RESTful. It's kind of an RPC call and we want to keep this library as generic as possible for RESTful API. On the other hand, it makes sense to check the token before POSTing something to the backend.

I was looking at the piece of code you linked from django-allauth on Friday. It makes sense to do it this way for them as they are not RESTful and they can use session.

There are a few things I don't like about checking if token is expired. One of the downsides of this solution would be that it can still expire between checking validity and posting data with the token.

I was thinking about a similar solution as they have with POSTing token first and then setting new password. Sadly I can't give it a thought right now. I will keep this ticket open and additional input from you or other folks is welcome.

rodnaskorn commented 2 years ago

This might be late but I found an example of the functionality you are trying to accomplish using django auth_views and using the {{ validlink }} template tag. From the book Django 3 by Example, Chapter 4 covers user login, register, password reset, etc. The example follows this logic: 1) User requests a password reset and provides an email 2) Django sends an email with a password reset URL. 3) The user clicks the link and opens a reset password page that contains a template where the new password is provided. But before doing this, there is a template tag called {{ validlink }} that checks to see if the link is still good. It is used in an IF statement:

{% block content %} {% if validlink %}

Enter the new password:

    <form method="post">
        {{ form.as_p }}
        {% csrf_token %}
        <p><input type="submit" value="Change password" /></p>
    </form>
{% else %}
    <p>Invalid reset link. Request a new password reset</p>
{% endif %}

{% endblock %}

If validlink is True, it displays the update password form. If validlink returns False it displays the message.

I think this is the functionality requested.

ali-issa commented 2 years ago

@tomwojcik I'm using Next.js as my frontend and Django with Djoser as my backend. The http://localhost:3000/password-reset/{uid}/{token} is considered as a dynamic route on my frontend, so without a way to check if the generated URL is valid, the users will always be able to access http://localhost:3000/password-reset/abc/def and view the set a new password page (it won't work obviously, but it doesn't make a lot of sense for them to be able to access the page).

Do you have any idea how other developers are handling this issue?

I've noticed in other implementation that a signature key is included in the reset links so one solution I can think of is encrypting the URL with a secret key before sending it from Django and then decrypting it on the frontend, but I'm not sure if there's a simpler solution and I'm overcomplicating things.

This also applies to the email verification URLs.

Pointing me to the right direction would be greatly appreciated.

rodnaskorn commented 2 years ago

@ali-issa I am still new to django but tackled similar problems recently. I think you need to look into django authentication (https://docs.djangoproject.com/en/4.0/topics/auth/default/), specifically the PasswordResetView and the PasswordResetConfirmView.

Djoser is essentially a wrapper that uses the Django Authentication System. What I did was use Djoser for registering, logging in/logout; but for resetting the password I ignored the djoser default and used the Django Authentication System directly. If you dig into the reset_password method of the UserViewSet in djoser, you can see that it doesn't verify the uid/token; but the django auth system does.

In my django project I have an app called account that handles all my log-in/log-out, etc.

account.urls:

`from django.contrib.auth import views as auth_views

urlpatterns = [ ... path('password_reset/', auth_views.PasswordResetView.as_view(), name='password_reset'), path('password_reset/done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'), path('reset///', auth_views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'), path('reset/done/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'), ... ]`

In my log-in template I have a link that says: Forgot your password? When you click it, it routes to account/password_reset/ url and asks the user for e-mail and sends the reset password email.

The user receives the email with link: account/reset/{uid}/{token}. As you can see, this request is routed to PasswordResetConfirmView (belonging to the Django Authentication System) this view verifies that the uid+token is valid (belongs to a user and hasn't been used yet). If it isn't valid, it won't show the reset password form.

I suggest you read the Django Authentication System documentation and look into the code to understand how it works. The book I suggest in my post above really helped me to understand how authentication works.

If you want to get your hands dirty, you can always override the reset_password method from the UserViewSet in djoser and add the verify uid/token functionality.

tomwojcik commented 2 years ago

@ali-issa FYI I'm no longer affiliated with the project, but I still think a custom endpoint that validates token against the uid should do the trick.

Basically something like

    @action(["get"], detail=False)
    def something(self, request, *args, **kwargs):
        serializer = serializer_for_reset_password_confirm
        if serializer.is_valid(raise_exception=False):
            return Response(status=status.HTTP_204)
        return Response(status=status.HTTP_400)
ali-issa commented 2 years ago

@tomwojcik This should do the job. Thank you for taking the time to answer my question.

idkthisnik commented 11 months ago

here is how I fixed it. i've created get endpoint with this view:

class CheckUIDandToken(APIView):
    permission_classes = [AllowAny]

    def get(self, request, uid, token):
        serializer = UIDandTokenSerializer(data={'uid': uid, 'token': token})
        if serializer.is_valid():
            return Response(status=204)
        return Response(status=404)

next thing I do, I've wrote serializer:

 class UIDandTokenSerializer(serializers.Serializer):
    uid = serializers.SlugField()
    token = serializers.SlugField()

    def validate(self, data):
        try:
            uid = urlsafe_base64_decode(data['uid'])
            user = User.objects.get(pk=uid)
            if default_token_generator.check_token(user, data['token']):
                return True
            else:
                raise serializers.ValidationError
        except Exception:
            raise serializers.ValidationError

So if uid or token is not valid, enpoint will return 404 but if valid it's return 204. On React frontend I call it with useeffect hook, when page rendering, if 204 user can write new password and re password, but if 404 user can't.