Skip to content

Change lookup value to dynamic value #93

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
[![PyPI version](https://badge.fury.io/py/django-rest-passwordreset.svg)](https://badge.fury.io/py/django-rest-passwordreset)
[![Build Status](https://travis-ci.org/anexia-it/django-rest-passwordreset.svg?branch=master)](https://travis-ci.org/anexia-it/django-rest-passwordreset)

This python package provides a simple password reset strategy for django rest framework, where users can request password
reset tokens via their registered e-mail address.
This python package provides a simple password reset strategy for django rest framework, where users can request password
reset tokens via a identifier (e-mail, username...).

The main idea behind this package is to not make any assumptions about how the token is delivered to the end-user (e-mail, text-message, etc...).
Instead, this package provides a signal that can be reacted on (e.g., by sending an e-mail or a text message).
Expand Down Expand Up @@ -48,20 +48,20 @@ urlpatterns = [
...
url(r'^api/password_reset/', include('django_rest_passwordreset.urls', namespace='password_reset')),
...
]
]
```
**Note**: You can adapt the url to your needs.

### Endpoints

The following endpoints are provided:

* `POST ${API_URL}/reset_password/` - request a reset password token by using the ``email`` parameter
* `POST ${API_URL}/reset_password/` - request a reset password token by using the name of the identifier (ex: ``{"username": "mycoolusername"}``)
* `POST ${API_URL}/reset_password/confirm/` - using a valid ``token``, the users password is set to the provided ``password``
* `POST ${API_URL}/reset_password/validate_token/` - will return a 200 if a given ``token`` is valid

where `${API_URL}/` is the url specified in your *urls.py* (e.g., `api/password_reset/`)

### Signals

* ``reset_password_token_created(sender, instance, reset_password_token)`` Fired when a reset password token is generated
Expand Down Expand Up @@ -122,7 +122,7 @@ def password_reset_token_created(sender, instance, reset_password_token, *args,

```

3. You should now be able to use the endpoints to request a password reset token via your e-mail address.
3. You should now be able to use the endpoints to request a password reset token via your e-mail address.
If you want to test this locally, I recommend using some kind of fake mailserver (such as maildump).


Expand All @@ -136,14 +136,14 @@ The following settings can be set in Djangos ``settings.py`` file:
**Please note**: expired tokens are automatically cleared based on this setting in every call of ``ResetPasswordRequestToken.post``.

* `DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE` - will cause a 200 to be returned on `POST ${API_URL}/reset_password/`
even if the user doesn't exist in the databse (Default: False)
even if the user doesn't exist in the databse (Default: False)

* `DJANGO_REST_MULTITOKENAUTH_REQUIRE_USABLE_PASSWORD` - allows password reset for a user that does not
* `DJANGO_REST_MULTITOKENAUTH_REQUIRE_USABLE_PASSWORD` - allows password reset for a user that does not
[have a usable password](https://docs.djangoproject.com/en/2.2/ref/contrib/auth/#django.contrib.auth.models.User.has_usable_password) (Default: True)

## Custom Email Lookup

By default, `email` lookup is used to find the user instance. You can change that by adding
By default, `email` lookup is used to find the user instance. You can change that by adding
```python
DJANGO_REST_LOOKUP_FIELD = 'custom_email_field'
Comment on lines 144 to 148
Copy link
Contributor

@anx-cbenke anx-cbenke Jul 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The heading is no longer correct when the lookup-field can be anything, not only email.

'custom_email_field' should be changed to a more generic value too, to make clear that the lookup-field is not limited to an email-adress

```
Expand All @@ -169,7 +169,7 @@ By default, a random string token of length 10 to 50 is generated using the ``Ra
This library offers a possibility to configure the params of ``RandomStringTokenGenerator`` as well as switch to
another token generator, e.g. ``RandomNumberTokenGenerator``. You can also generate your own token generator class.

You can change that by adding
You can change that by adding
```python
DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = {
"CLASS": ...,
Expand All @@ -180,7 +180,7 @@ into Django settings.py file.


### RandomStringTokenGenerator
This is the default configuration.
This is the default configuration.
```python
DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = {
"CLASS": "django_rest_passwordreset.tokens.RandomStringTokenGenerator"
Expand All @@ -199,7 +199,7 @@ DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = {
```

It uses `os.urandom()` to generate a good random string.


### RandomNumberTokenGenerator
```python
Expand Down Expand Up @@ -304,7 +304,7 @@ You need to make sure that the code with `@receiver(reset_password_token_created
def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs):
# ...
```

*some_app/app.py*
```python
from django.apps import AppConfig
Expand All @@ -316,7 +316,7 @@ You need to make sure that the code with `@receiver(reset_password_token_created
def ready(self):
import your_django_project.some_app.signals # noqa
```

*some_app/__init__.py*
```python
default_app_config = 'your_django_project.some_app.SomeAppConfig'
Expand All @@ -327,16 +327,16 @@ You need to make sure that the code with `@receiver(reset_password_token_created
Apparently, the following piece of code in the Django Model prevents MongodB from working:

```python
id = models.AutoField(
primary_key=True
)
id = models.AutoField(
primary_key=True
)
```

See issue #49 for details.

## Contributions

This library tries to follow the unix philosophy of "do one thing and do it well" (which is providing a basic password reset endpoint for Django Rest Framework). Contributions are welcome in the form of pull requests and issues! If you create a pull request, please make sure that you are not introducing breaking changes.
This library tries to follow the unix philosophy of "do one thing and do it well" (which is providing a basic password reset endpoint for Django Rest Framework). Contributions are welcome in the form of pull requests and issues! If you create a pull request, please make sure that you are not introducing breaking changes.

## Tests

Expand Down
16 changes: 11 additions & 5 deletions django_rest_passwordreset/serializers.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
from django.utils.translation import ugettext_lazy as _

from rest_framework import serializers
from rest_framework.utils.serializer_helpers import BindingDict
from django.utils.functional import cached_property
from django_rest_passwordreset.models import get_password_reset_lookup_field

__all__ = [
'EmailSerializer',
'LookupSerializer',
'PasswordTokenSerializer',
'TokenSerializer',
]


class EmailSerializer(serializers.Serializer):
email = serializers.EmailField()

class LookupSerializer(serializers.Serializer):
@cached_property
def fields(self):
fields = BindingDict(self)
lookup_field = get_password_reset_lookup_field()
fields[lookup_field] = serializers.CharField()
return fields

class PasswordTokenSerializer(serializers.Serializer):
password = serializers.CharField(label=_("Password"), style={'input_type': 'password'})
Expand Down
16 changes: 9 additions & 7 deletions django_rest_passwordreset/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response

from django_rest_passwordreset.serializers import EmailSerializer, PasswordTokenSerializer, TokenSerializer
from django_rest_passwordreset.serializers import LookupSerializer, PasswordTokenSerializer, TokenSerializer
from django_rest_passwordreset.models import ResetPasswordToken, clear_expired, get_password_reset_token_expiry_time, \
get_password_reset_lookup_field
from django_rest_passwordreset.signals import reset_password_token_created, pre_password_reset, post_password_reset
Expand Down Expand Up @@ -58,7 +58,7 @@ def post(self, request, *args, **kwargs):
# delete expired token
reset_password_token.delete()
return Response({'status': 'expired'}, status=status.HTTP_404_NOT_FOUND)

return Response({'status': 'OK'})


Expand Down Expand Up @@ -127,12 +127,14 @@ class ResetPasswordRequestToken(GenericAPIView):
"""
throttle_classes = ()
permission_classes = ()
serializer_class = EmailSerializer
serializer_class = LookupSerializer

def post(self, request, *args, **kwargs):
lookup_field = get_password_reset_lookup_field()

serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
email = serializer.validated_data['email']
identifier = serializer.validated_data[lookup_field]

# before we continue, delete all existing expired tokens
password_reset_token_validation_time = get_password_reset_token_expiry_time()
Expand All @@ -144,7 +146,7 @@ def post(self, request, *args, **kwargs):
clear_expired(now_minus_expiry_time)

# find a user by email address (case insensitive search)
users = User.objects.filter(**{'{}__iexact'.format(get_password_reset_lookup_field()): email})
users = User.objects.filter(**{'{}__iexact'.format(lookup_field): identifier})

active_user_found = False

Expand All @@ -159,8 +161,8 @@ def post(self, request, *args, **kwargs):
# but not if DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE == True
if not active_user_found and not getattr(settings, 'DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE', False):
raise exceptions.ValidationError({
'email': [_(
"There is no active user associated with this e-mail address or the password can not be changed")],
lookup_field: [_(
"There is no active user associated with this {} address or the password can not be changed".format(lookup_field))],
})

# last but not least: iterate over all users that are active and can change their password
Expand Down
6 changes: 2 additions & 4 deletions tests/test/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,9 @@ def django_check_login(self, username, password):

return user.check_password(password)

def rest_do_request_reset_token(self, email, HTTP_USER_AGENT='', REMOTE_ADDR='127.0.0.1'):
def rest_do_request_reset_token(self, HTTP_USER_AGENT='', REMOTE_ADDR='127.0.0.1', **kwargs):
""" REST API wrapper for requesting a password reset token """
data = {
'email': email
}
data = kwargs

return self.client.post(
self.reset_password_request_url,
Expand Down
11 changes: 5 additions & 6 deletions tests/test/test_auth_test_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def test_validate_bad_token(self):
# try to validate an invalid token
response = self.rest_do_validate_token("not_a_valid_token")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

# there should be zero tokens
self.assertEqual(ResetPasswordToken.objects.all().count(), 0)

Expand Down Expand Up @@ -173,7 +173,7 @@ def test_reset_password_different_lookup(self, mock_reset_password_token_created
# there should be zero tokens
self.assertEqual(ResetPasswordToken.objects.all().count(), 0)

response = self.rest_do_request_reset_token(email="[email protected]")
response = self.rest_do_request_reset_token(username="[email protected]")
self.assertEqual(response.status_code, status.HTTP_200_OK)
# check that the signal was sent once
self.assertTrue(mock_reset_password_token_created.called)
Expand All @@ -185,7 +185,7 @@ def test_reset_password_different_lookup(self, mock_reset_password_token_created
self.assertEqual(ResetPasswordToken.objects.all().count(), 1)

# if the same user tries to reset again, the user will get the same token again
response = self.rest_do_request_reset_token(email="[email protected]")
response = self.rest_do_request_reset_token(username="[email protected]")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(mock_reset_password_token_created.call_count, 2)
last_reset_password_token = mock_reset_password_token_created.call_args[1]['reset_password_token']
Expand Down Expand Up @@ -315,10 +315,10 @@ def test_signals(self,

@override_settings(DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE=True)
def test_try_reset_password_email_does_not_exist_no_leakage_enabled(self):
"""
"""
Tests requesting a token for an email that does not exist when
DJANGO_REST_PASSWORDRESET_NO_INFORMATION_LEAKAGE == True
"""
"""
response = self.rest_do_request_reset_token(email="[email protected]")
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_user_without_password(self):
Expand Down Expand Up @@ -372,4 +372,3 @@ def test_user_without_password_where_not_required(self, mock_reset_password_toke
self.django_check_login("user4", "new_secret"),
msg="User 4 should be able to login with the modified credentials"
)