Skip to content

Added reset by phone number functionality #74

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

Closed
Closed
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
39 changes: 20 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 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).
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 ``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
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,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.

Expand All @@ -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": ...,
Expand All @@ -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"
Expand All @@ -199,7 +200,7 @@ DJANGO_REST_PASSWORDRESET_TOKEN_CONFIG = {
```

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


### RandomNumberTokenGenerator
```python
Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -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

Expand Down
11 changes: 10 additions & 1 deletion django_rest_passwordreset/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
25 changes: 22 additions & 3 deletions django_rest_passwordreset/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Copy link

@pinheiro-bruno pinheiro-bruno Jan 15, 2020

Choose a reason for hiding this comment

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

This message doesn't make clear for user which fields needs to be provided. phone? phone_number?

-raise serializers.ValidationError('Please provide Email or Phone Number')
+raise serializers.ValidationError('Please provide email or phone field')

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):
Expand Down
19 changes: 12 additions & 7 deletions django_rest_passwordreset/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
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,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()
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions requirements_test.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
Django==1.11.*
djangorestframework==3.9.*
phonenumbers==8.10.*
mock==2.0.0
6 changes: 5 additions & 1 deletion tests/test/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down