Skip to content

Commit

Permalink
[change!] Allowed username and phone_number in password reset API
Browse files Browse the repository at this point in the history
**Backward incompatible change**: The password reset API endpoint now
accepts "input" parameter instead of "email".
  • Loading branch information
pandafy authored Feb 2, 2022
1 parent d040b21 commit 81cefaf
Show file tree
Hide file tree
Showing 4 changed files with 56 additions and 14 deletions.
8 changes: 4 additions & 4 deletions docs/source/user/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -482,11 +482,11 @@ Responds only to **POST**.

Parameters:

=============== ===============================
=============== ======================================================
Param Description
=============== ===============================
email string
=============== ===============================
=============== ======================================================
input string that can be an email, phone_number or username.
=============== ======================================================

Confirm reset password
----------------------
Expand Down
10 changes: 10 additions & 0 deletions openwisp_radius/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,18 @@ class Meta:


class PasswordResetSerializer(BasePasswordResetSerializer):
input = serializers.CharField()
email = None
password_reset_form_class = PasswordResetForm

def validate_input(self, value):
# Create PasswordResetForm with the serializer.
# Check BasePasswordResetSerializer.validate_email for details.
user = self.context.get('request').user
self.reset_form = self.password_reset_form_class(data={'email': user.email})
self.reset_form.is_valid()
return value

def save(self):
request = self.context.get('request')
password_reset_url = self.context.get('password_reset_url')
Expand Down
15 changes: 10 additions & 5 deletions openwisp_radius/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -44,6 +43,7 @@
from openwisp_users.api.authentication import BearerAuthentication, SesameAuthentication
from openwisp_users.api.permissions import IsOrganizationManager
from openwisp_users.api.views import ChangePasswordView as BasePasswordChangeView
from openwisp_users.backends import UsersAuthenticationBackend

from .. import settings as app_settings
from ..exceptions import PhoneTokenException, UserAlreadyVerified
Expand Down Expand Up @@ -76,6 +76,7 @@
RadiusAccounting = load_model('RadiusAccounting')
RadiusToken = load_model('RadiusToken')
RadiusBatch = load_model('RadiusBatch')
auth_backend = UsersAuthenticationBackend()


class ThrottledAPIMixin(object):
Expand Down Expand Up @@ -466,14 +467,16 @@ class PasswordResetView(ThrottledAPIMixin, DispatchOrgMixin, BasePasswordResetVi
@swagger_auto_schema(
responses={
200: '`{"detail": "Password reset e-mail has been sent."}`',
400: '`{"detail": "The email field is required."}`',
400: '`{"detail": "The input field is required."}`',
404: '`{"detail": "Not found."}`',
}
)
def post(self, request, *args, **kwargs):
"""
This is the classic "password forgotten recovery feature" which
sends a reset password token to the email of the user.
The input field can be an email, an username or
a phone number (if mobile phone verification is in use).
"""
request.user = self.get_user(request)
return super().post(request, *args, **kwargs)
Expand All @@ -500,9 +503,11 @@ def get_serializer_context(self):
return context

def get_user(self, request):
if request.data.get('email', None):
email = request.data['email']
user = get_object_or_404(User, email=email)
if request.data.get('input', None):
input = request.data['input']
user = auth_backend.get_users(input).first()
if user is None:
raise Http404('No user was found with given details.')
self.validate_membership(user)
return user
raise ParseError(_('The email field is required.'))
Expand Down
37 changes: 32 additions & 5 deletions openwisp_radius/tests/test_api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,9 +668,13 @@ def test_api_password_change(self):
self.assertEqual(response.status_code, 401)

@capture_any_output()
@mock.patch('openwisp_users.settings.AUTH_BACKEND_AUTO_PREFIXES', ['+33'])
def test_api_password_reset(self):
test_user = User.objects.create_user(
username='test_name', password='test_password', email='test@email.com'
username='test_name',
password='test_password',
email='test@email.com',
phone_number='+33675579231',
)
self._create_org_user(organization=self.default_org, user=test_user)
mail_count = len(mail.outbox)
Expand All @@ -692,20 +696,20 @@ def test_api_password_reset(self):
self.assertEqual(response.status_code, 400)

# email does not exist in database
reset_payload = {'email': 'wrong@email.com'}
reset_payload = {'input': 'wrong@email.com'}
response = self.client.post(password_reset_url, data=reset_payload)
self.assertEqual(response.status_code, 404)

# email not registered with org
User.objects.create_user(
username='test_name1', password='test_password', email='test1@email.com'
)
reset_payload = {'email': 'test1@email.com'}
reset_payload = {'input': 'test1@email.com'}
response = self.client.post(password_reset_url, data=reset_payload)
self.assertEqual(response.status_code, 400)

# valid payload
reset_payload = {'email': 'test@email.com'}
reset_payload = {'input': 'test@email.com'}
response = self.client.post(password_reset_url, data=reset_payload)
self.assertEqual(len(mail.outbox), mail_count + 1)
email = mail.outbox.pop()
Expand Down Expand Up @@ -780,6 +784,29 @@ def test_api_password_reset(self):
login_response = self.client.post(login_url, data=login_payload)
self.assertEqual(login_response.status_code, 200)

with self.subTest('Test reset password with username'):
reset_payload = {'input': test_user.username}
response = self.client.post(password_reset_url, data=reset_payload)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(mail.outbox), mail_count + 1)
mail.outbox.pop()

with self.subTest('Test reset password with phone_number'):
reset_payload = {'input': test_user.phone_number}
response = self.client.post(password_reset_url, data=reset_payload)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(mail.outbox), mail_count + 1)
mail.outbox.pop()

with self.subTest(
'Test reset password with phone_number without country prefix'
):
reset_payload = {'input': test_user.phone_number.national_number}
response = self.client.post(password_reset_url, data=reset_payload)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(mail.outbox), mail_count + 1)
mail.outbox.pop()

def test_api_password_reset_405(self):
password_reset_url = reverse(
'radius:rest_password_reset', args=[self.default_org.slug]
Expand Down Expand Up @@ -873,7 +900,7 @@ def _test_user_reset_password_helper(self, is_active, mocked_send):
org = self._get_org()
self._create_org_user(user=user, organization=org)
path = reverse('radius:rest_password_reset', args=[org.slug])
r = self.client.post(path, {'email': user.email})
r = self.client.post(path, {'input': user.email})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data['detail'], 'Password reset e-mail has been sent.')
mocked_send.assert_called_once()
Expand Down

0 comments on commit 81cefaf

Please sign in to comment.