diff --git a/README.md b/README.md index a703cb7..44050bf 100644 --- a/README.md +++ b/README.md @@ -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). @@ -48,7 +48,7 @@ urlpatterns = [ ... url(r'^api/password_reset/', include('django_rest_passwordreset.urls', namespace='password_reset')), ... -] +] ``` **Note**: You can adapt the url to your needs. @@ -56,12 +56,12 @@ urlpatterns = [ 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 @@ -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). @@ -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' ``` @@ -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": ..., @@ -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" @@ -199,7 +199,7 @@ DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = { ``` It uses `os.urandom()` to generate a good random string. - + ### RandomNumberTokenGenerator ```python @@ -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 @@ -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' @@ -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 diff --git a/django_rest_passwordreset/serializers.py b/django_rest_passwordreset/serializers.py index 5d87447..6bd70e0 100644 --- a/django_rest_passwordreset/serializers.py +++ b/django_rest_passwordreset/serializers.py @@ -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'}) diff --git a/django_rest_passwordreset/views.py b/django_rest_passwordreset/views.py index 829d94c..32c931a 100644 --- a/django_rest_passwordreset/views.py +++ b/django_rest_passwordreset/views.py @@ -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 @@ -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'}) @@ -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() @@ -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 @@ -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 diff --git a/tests/test/helpers.py b/tests/test/helpers.py index f38aac5..0bc6635 100644 --- a/tests/test/helpers.py +++ b/tests/test/helpers.py @@ -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, diff --git a/tests/test/test_auth_test_case.py b/tests/test/test_auth_test_case.py index 62c1fdd..188fd23 100644 --- a/tests/test/test_auth_test_case.py +++ b/tests/test/test_auth_test_case.py @@ -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) @@ -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="user3@mail.com") + response = self.rest_do_request_reset_token(username="user3@mail.com") self.assertEqual(response.status_code, status.HTTP_200_OK) # check that the signal was sent once self.assertTrue(mock_reset_password_token_created.called) @@ -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="user3@mail.com") + response = self.rest_do_request_reset_token(username="user3@mail.com") 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'] @@ -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="foobar@doesnotexist.com") self.assertEqual(response.status_code, status.HTTP_200_OK) def test_user_without_password(self): @@ -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" ) -