From 6d7d7456e5493bf30299a051c86e85163fe80cea Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 18 Nov 2019 01:30:26 +0100 Subject: [PATCH 1/2] modified helper module --- tests/test/helpers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test/helpers.py b/tests/test/helpers.py index f068546..4276e97 100644 --- a/tests/test/helpers.py +++ b/tests/test/helpers.py @@ -42,11 +42,15 @@ 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, email=None, phone=None, HTTP_USER_AGENT='', REMOTE_ADDR='127.0.0.1'): """ REST API wrapper for requesting a password reset token """ data = { 'email': email } + if phone: + data = { + 'phone': phone + } return self.client.post( self.reset_password_request_url, From 2b5afad1cc3096caf45b66cbee97c32b7c2f6af9 Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 18 Nov 2019 01:33:20 +0100 Subject: [PATCH 2/2] added phone number reset capability --- README.md | 39 ++++++++++++------------ django_rest_passwordreset/models.py | 11 ++++++- django_rest_passwordreset/serializers.py | 25 +++++++++++++-- django_rest_passwordreset/views.py | 19 +++++++----- requirements_test.txt | 1 + 5 files changed, 65 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index cc04132..242dfb9 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 their registered e-mail address or phone number. 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 ``email`` or ``phone number`` parameter * `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,16 +136,17 @@ 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 by email, and ``profile__telephone`` is used to find user instance by phone number You can change that by adding ```python DJANGO_REST_LOOKUP_FIELD = 'custom_email_field' +DJANGO_REST_PHONE_LOOKUP_FIELD = 'custom_phone_field' ``` into Django settings.py file. @@ -169,7 +170,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 +181,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 +200,7 @@ DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = { ``` It uses `os.urandom()` to generate a good random string. - + ### RandomNumberTokenGenerator ```python @@ -304,7 +305,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 +317,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 +328,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/models.py b/django_rest_passwordreset/models.py index ac16611..ddf5d89 100644 --- a/django_rest_passwordreset/models.py +++ b/django_rest_passwordreset/models.py @@ -100,6 +100,15 @@ def get_password_reset_lookup_field(): return getattr(settings, 'DJANGO_REST_LOOKUP_FIELD', 'email') +def get_phone_password_reset_lookup_field(): + """ + Returns the password reset lookup field (default: email) + Set Django SETTINGS.DJANGO_REST_PHONE_LOOKUP_FIELD to overwrite this time + :return: lookup field + """ + return getattr(settings, 'DJANGO_REST_PHONE_LOOKUP_FIELD', 'profile__telephone') + + def clear_expired(expiry_time): """ Remove all expired tokens @@ -111,7 +120,7 @@ def eligible_for_reset(self): if not self.is_active: # if the user is active we dont bother checking return False - + if getattr(settings, 'DJANGO_REST_MULTITOKENAUTH_REQUIRE_USABLE_PASSWORD', True): # if we require a usable password then return the result of has_usable_password() return self.has_usable_password() diff --git a/django_rest_passwordreset/serializers.py b/django_rest_passwordreset/serializers.py index 5d87447..35b28a9 100644 --- a/django_rest_passwordreset/serializers.py +++ b/django_rest_passwordreset/serializers.py @@ -8,9 +8,28 @@ 'TokenSerializer', ] - -class EmailSerializer(serializers.Serializer): - email = serializers.EmailField() +def check_phone(phone): + import phonenumbers + try: + phone_obj = phonenumbers.parse(phone, None) + return True, phone_obj + except phonenumbers.phonenumberutil.NumberParseException as e: + return False, e._msg + +class EmailPhoneSerializer(serializers.Serializer): + email = serializers.EmailField(required=False) + phone = serializers.CharField(required=False) + + def validate(self, attrs): + if not attrs.get("email") and not attrs.get("phone"): + raise serializers.ValidationError('Please provide Email or Phone Number') + return attrs + + def validate_phone(self, attrs): + validate_phone = check_phone(attrs) + if not validate_phone[0]: + raise serializers.ValidationError(validate_phone[1]) + return attrs class PasswordTokenSerializer(serializers.Serializer): diff --git a/django_rest_passwordreset/views.py b/django_rest_passwordreset/views.py index 829d94c..9133af5 100644 --- a/django_rest_passwordreset/views.py +++ b/django_rest_passwordreset/views.py @@ -9,9 +9,9 @@ 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 EmailPhoneSerializer, PasswordTokenSerializer, TokenSerializer from django_rest_passwordreset.models import ResetPasswordToken, clear_expired, get_password_reset_token_expiry_time, \ - get_password_reset_lookup_field + get_password_reset_lookup_field, get_phone_password_reset_lookup_field from django_rest_passwordreset.signals import reset_password_token_created, pre_password_reset, post_password_reset User = get_user_model() @@ -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,13 @@ class ResetPasswordRequestToken(GenericAPIView): """ throttle_classes = () permission_classes = () - serializer_class = EmailSerializer + serializer_class = EmailPhoneSerializer def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) - email = serializer.validated_data['email'] + email = serializer.validated_data.get('email') + phone = serializer.validated_data.get('phone') # before we continue, delete all existing expired tokens password_reset_token_validation_time = get_password_reset_token_expiry_time() @@ -143,8 +144,12 @@ def post(self, request, *args, **kwargs): # delete all tokens where created_at < now - 24 hours 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}) + if email: + # find a user by email address (case insensitive search) + users = User.objects.filter(**{'{}__iexact'.format(get_password_reset_lookup_field()): email}) + else: + # find a user by phone number (case insensitive search) + users = User.objects.filter(**{'{}__iexact'.format(get_phone_password_reset_lookup_field()): phone}) active_user_found = False diff --git a/requirements_test.txt b/requirements_test.txt index 05162b4..9b0d6f8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,3 +1,4 @@ Django==1.11.* djangorestframework==3.9.* +phonenumbers==8.10.* mock==2.0.0