diff --git a/README.md b/README.md index 79ba22c..ef2fc61 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ curl -X POST -d "mobile=+15552143912" localhost:8000/auth/mobile/ TokenAuthentication scheme. ```bash -curl -X POST -d "token=815381" localhost:8000/callback/auth/ +curl -X POST -d "email=aaron@email.com&token=815381" localhost:8000/auth/token/ > HTTP/1.0 200 OK > {"token":"76be2d9ecfaf5fa4226d722bzdd8a4fff207ed0e”} @@ -149,19 +149,11 @@ You’ll also need to set up an SMTP server to send emails (`See Django Docs `__), but for development you can set up a dummy development smtp server to test emails. Sent emails will print to the console. `Read more -here. `__ +here. `__ ```python # Settings.py -… -EMAIL_HOST = 'localhost' -EMAIL_PORT = 1025 -``` - -Then run the following: - -```bash -python -m smtpd -n -c DebuggingServer localhost:1025 +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' ``` Configuring Mobile @@ -210,11 +202,11 @@ This is off by default but can be turned on with enabled they look for the User model fields ``email_verified`` or ``mobile_verified``. -You can also use ``/validate/email/`` or ``/validate/mobile/`` which will +You can also use ``auth/verify/email/`` or ``/auth/verify/mobile/`` which will automatically send a token to the endpoint attached to the current ``request.user``'s email or mobile if available. -You can then send that token to ``/callback/verify/`` which will double-check +You can then send that token to ``/auth/verify/`` which will double-check that the endpoint belongs to the request.user and mark the alias as verified. Registration @@ -239,6 +231,12 @@ DEFAULTS = { # Allowed auth types, can be EMAIL, MOBILE, or both. 'PASSWORDLESS_AUTH_TYPES': ['EMAIL'], + # URL Prefix for Authentication Endpoints + 'PASSWORDLESS_AUTH_PREFIX': 'auth', + + # URL Prefix for Verification Endpoints + 'PASSWORDLESS_VERIFY_PREFIX': 'auth', + # Amount of time that tokens last, in seconds 'PASSWORDLESS_TOKEN_EXPIRE_TIME': 15 * 60, @@ -338,7 +336,7 @@ License The MIT License (MIT) -Copyright (c) 2018 Aaron Ng +Copyright (c) 2020 Aaron Ng Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/drfpasswordless/__init__.py b/drfpasswordless/__init__.py index ecdfc2a..7c8b987 100644 --- a/drfpasswordless/__init__.py +++ b/drfpasswordless/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = 'drfpasswordless' -__version__ = '1.4.0' +__version__ = '1.5.0' __author__ = 'Aaron Ng' __license__ = 'MIT' __copyright__ = 'Copyright 2020 Aaron Ng' diff --git a/drfpasswordless/__version__.py b/drfpasswordless/__version__.py index d9e5b37..d7ecfd8 100644 --- a/drfpasswordless/__version__.py +++ b/drfpasswordless/__version__.py @@ -1,3 +1,3 @@ -VERSION = (1, 4, 0) +VERSION = (1, 5, 0) __version__ = '.'.join(map(str, VERSION)) diff --git a/drfpasswordless/admin.py b/drfpasswordless/admin.py index d983d07..268d699 100644 --- a/drfpasswordless/admin.py +++ b/drfpasswordless/admin.py @@ -19,8 +19,8 @@ def link_to_user(self, obj): class AbstractCallbackTokenInline(admin.StackedInline): max_num = 0 extra = 0 - readonly_fields = ('created_at', 'key', 'is_active') - fields = ('created_at', 'user', 'key', 'is_active') + readonly_fields = ('created_at', 'key', 'type', 'is_active') + fields = ('created_at', 'user', 'key', 'type', 'is_active') class CallbackInline(AbstractCallbackTokenInline): @@ -28,7 +28,7 @@ class CallbackInline(AbstractCallbackTokenInline): class AbstractCallbackTokenAdmin(UserLinkMixin, admin.ModelAdmin): - readonly_fields = ('created_at', 'user', 'key') - list_display = ('created_at', UserLinkMixin.LINK_TO_USER_FIELD, 'key', 'is_active') - fields = ('created_at', 'user', 'key', 'is_active') + readonly_fields = ('created_at', 'user', 'key', 'type',) + list_display = ('created_at', UserLinkMixin.LINK_TO_USER_FIELD, 'key', 'type', 'is_active') + fields = ('created_at', 'user', 'key', 'type', 'is_active') extra = 0 diff --git a/drfpasswordless/migrations/0003_callbacktoken_type.py b/drfpasswordless/migrations/0003_callbacktoken_type.py new file mode 100644 index 0000000..2160ae2 --- /dev/null +++ b/drfpasswordless/migrations/0003_callbacktoken_type.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.2 on 2020-01-22 08:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('drfpasswordless', '0002_auto_20200122_0424'), + ] + + operations = [ + migrations.AddField( + model_name='callbacktoken', + name='type', + field=models.CharField(choices=[('AUTH', 'Auth'), ('VERIFY', 'Verify')], default='VERIFY', max_length=20), + preserve_default=False, + ), + ] diff --git a/drfpasswordless/models.py b/drfpasswordless/models.py index f657b16..bdac4ba 100644 --- a/drfpasswordless/models.py +++ b/drfpasswordless/models.py @@ -33,6 +33,7 @@ class AbstractBaseCallbackToken(models.Model): When a new token is created, older ones of the same type are invalidated via the pre_save signal in signals.py. """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True) created_at = models.DateTimeField(auto_now_add=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name=None, on_delete=models.CASCADE) @@ -56,7 +57,12 @@ class CallbackToken(AbstractBaseCallbackToken): """ Generates a random six digit number to be returned. """ + TOKEN_TYPE_AUTH = 'AUTH' + TOKEN_TYPE_VERIFY = 'VERIFY' + TOKEN_TYPES = ((TOKEN_TYPE_AUTH, 'Auth'), (TOKEN_TYPE_VERIFY, 'Verify')) + key = models.CharField(default=generate_numeric_token, max_length=6, unique=True) + type = models.CharField(max_length=20, choices=TOKEN_TYPES) class Meta(AbstractBaseCallbackToken.Meta): verbose_name = 'Callback Token' diff --git a/drfpasswordless/serializers.py b/drfpasswordless/serializers.py index eb6e9c1..f59623c 100644 --- a/drfpasswordless/serializers.py +++ b/drfpasswordless/serializers.py @@ -4,6 +4,7 @@ from django.core.exceptions import PermissionDenied from django.core.validators import RegexValidator from rest_framework import serializers +from rest_framework.exceptions import ValidationError from drfpasswordless.models import CallbackToken from drfpasswordless.settings import api_settings from drfpasswordless.utils import authenticate_by_token, verify_user_alias, validate_token_age @@ -168,21 +169,48 @@ class AbstractBaseCallbackTokenSerializer(serializers.Serializer): Abstract class inspired by DRF's own token serializer. Returns a user if valid, None or a message if not. """ + phone_regex = RegexValidator(regex=r'^\+?1?\d{9,15}$', + message="Mobile number must be entered in the format:" + " '+999999999'. Up to 15 digits allowed.") + + email = serializers.EmailField(required=False) # Needs to be required=false to require both. + mobile = serializers.CharField(required=False, validators=[phone_regex], max_length=15) token = TokenField(min_length=6, max_length=6, validators=[token_age_validator]) + def validate_alias(self, attrs): + email = attrs.get('email', None) + mobile = attrs.get('mobile', None) + + if email and mobile: + raise serializers.ValidationError() + + if not email and not mobile: + raise serializers.ValidationError() + + if email: + return 'email', email + elif mobile: + return 'mobile', mobile + + return None + class CallbackTokenAuthSerializer(AbstractBaseCallbackTokenSerializer): def validate(self, attrs): - callback_token = attrs.get('token', None) - - token = CallbackToken.objects.get(key=callback_token, is_active=True) + # Check Aliases + try: + alias_type, alias = self.validate_alias(attrs) + callback_token = attrs.get('token', None) + user = User.objects.get(**{alias_type: alias}) + token = CallbackToken.objects.get(**{'user': user, + 'key': callback_token, + 'type': CallbackToken.TOKEN_TYPE_AUTH, + 'is_active': True}) - if token: - # Check the token type for our uni-auth method. - # authenticates and checks the expiry of the callback token. - user = authenticate_by_token(token) - if user: + if token.user == user: + # Check the token type for our uni-auth method. + # authenticates and checks the expiry of the callback token. if not user.is_active: msg = _('User account is disabled.') raise serializers.ValidationError(msg) @@ -203,8 +231,11 @@ def validate(self, attrs): else: msg = _('Invalid Token') raise serializers.ValidationError(msg) - else: - msg = _('Missing authentication token.') + except User.DoesNotExist: + msg = _('Invalid alias parameters provided.') + raise serializers.ValidationError(msg) + except ValidationError: + msg = _('Invalid alias parameters provided.') raise serializers.ValidationError(msg) @@ -216,15 +247,17 @@ class CallbackTokenVerificationSerializer(AbstractBaseCallbackTokenSerializer): def validate(self, attrs): try: + alias_type, alias = self.validate_alias(attrs) user_id = self.context.get("user_id") + user = User.objects.get(**{'id': user_id, alias_type: alias}) callback_token = attrs.get('token', None) - token = CallbackToken.objects.get(key=callback_token, is_active=True) - user = User.objects.get(pk=user_id) + token = CallbackToken.objects.get(**{'user': user, + 'key': callback_token, + 'type': CallbackToken.TOKEN_TYPE_VERIFY, + 'is_active': True}) if token.user == user: - # Check that the token.user is the request.user - # Mark this alias as verified success = verify_user_alias(user, token) if success is False: @@ -237,11 +270,11 @@ def validate(self, attrs): logger.debug("drfpasswordless: User token mismatch when verifying alias.") except CallbackToken.DoesNotExist: - msg = _('Missing authentication token.') + msg = _('We could not verify this alias.') logger.debug("drfpasswordless: Tried to validate alias with bad token.") pass except User.DoesNotExist: - msg = _('Missing user.') + msg = _('We could not verify this alias.') logger.debug("drfpasswordless: Tried to validate alias with bad user.") pass except PermissionDenied: diff --git a/drfpasswordless/services.py b/drfpasswordless/services.py index 42ca867..f165237 100644 --- a/drfpasswordless/services.py +++ b/drfpasswordless/services.py @@ -7,8 +7,8 @@ class TokenService(object): @staticmethod - def send_token(user, alias_type, **message_payload): - token = create_callback_token_for_user(user, alias_type) + def send_token(user, alias_type, token_type, **message_payload): + token = create_callback_token_for_user(user, alias_type, token_type) send_action = None if alias_type == 'email': send_action = send_email_with_callback_token diff --git a/drfpasswordless/settings.py b/drfpasswordless/settings.py index 24af854..cc33c27 100644 --- a/drfpasswordless/settings.py +++ b/drfpasswordless/settings.py @@ -8,6 +8,12 @@ # Allowed auth types, can be EMAIL, MOBILE, or both. 'PASSWORDLESS_AUTH_TYPES': ['EMAIL'], + # URL Prefix for Authentication Endpoints + 'PASSWORDLESS_AUTH_PREFIX': 'auth/', + + # URL Prefix for Verification Endpoints + 'PASSWORDLESS_VERIFY_PREFIX': 'auth/verify/', + # Amount of time that tokens last, in seconds 'PASSWORDLESS_TOKEN_EXPIRE_TIME': 15 * 60, diff --git a/drfpasswordless/signals.py b/drfpasswordless/signals.py index 7e02fd1..de68979 100644 --- a/drfpasswordless/signals.py +++ b/drfpasswordless/signals.py @@ -10,20 +10,13 @@ logger = logging.getLogger(__name__) -@receiver(signals.pre_save, sender=CallbackToken) -def invalidate_previous_tokens(sender, instance, **kwargs): +@receiver(signals.post_save, sender=CallbackToken) +def invalidate_previous_tokens(sender, instance, created, **kwargs): """ - Invalidates all previously issued tokens as a post_save signal. + Invalidates all previously issued tokens of that type when a new one is created, used, or anything like that. """ - active_tokens = None if isinstance(instance, CallbackToken): - active_tokens = CallbackToken.objects.active().filter(user=instance.user).exclude(id=instance.id) - - # Invalidate tokens - if active_tokens: - for token in active_tokens: - token.is_active = False - token.save() + CallbackToken.objects.active().filter(user=instance.user, type=instance.type).exclude(id=instance.id).update(is_active=False) @receiver(signals.pre_save, sender=CallbackToken) @@ -72,7 +65,7 @@ def update_alias_verification(sender, instance, **kwargs): message_payload = {'email_subject': email_subject, 'email_plaintext': email_plaintext, 'email_html': email_html} - success = TokenService.send_token(instance, 'email', **message_payload) + success = TokenService.send_token(instance, 'email', CallbackToken.TOKEN_TYPE_VERIFY, **message_payload) if success: logger.info('drfpasswordless: Successfully sent email on updated address: %s' @@ -104,7 +97,7 @@ def update_alias_verification(sender, instance, **kwargs): if api_settings.PASSWORDLESS_AUTO_SEND_VERIFICATION_TOKEN is True: mobile_message = api_settings.PASSWORDLESS_MOBILE_MESSAGE message_payload = {'mobile_message': mobile_message} - success = TokenService.send_token(instance, 'mobile', **message_payload) + success = TokenService.send_token(instance, 'mobile', CallbackToken.TOKEN_TYPE_VERIFY, **message_payload) if success: logger.info('drfpasswordless: Successfully sent SMS on updated mobile: %s' diff --git a/drfpasswordless/urls.py b/drfpasswordless/urls.py index 1fa8f69..08189d7 100644 --- a/drfpasswordless/urls.py +++ b/drfpasswordless/urls.py @@ -1,3 +1,4 @@ +from drfpasswordless.settings import api_settings from django.urls import path from drfpasswordless.views import ( ObtainEmailCallbackToken, @@ -8,11 +9,13 @@ ObtainMobileVerificationCallbackToken, ) +app_name = 'drfpasswordless' + urlpatterns = [ - path('callback/auth/', ObtainAuthTokenFromCallbackToken.as_view(), name='auth_callback'), - path('auth/email/', ObtainEmailCallbackToken.as_view(), name='auth_email'), - path('auth/mobile/', ObtainMobileCallbackToken.as_view(), name='auth_mobile'), - path('callback/verify/', VerifyAliasFromCallbackToken.as_view(), name='verify_callback'), - path('verify/email/', ObtainEmailVerificationCallbackToken.as_view(), name='verify_email'), - path('verify/mobile/', ObtainMobileVerificationCallbackToken.as_view(), name='verify_mobile'), + path(api_settings.PASSWORDLESS_AUTH_PREFIX + 'email/', ObtainEmailCallbackToken.as_view(), name='auth_email'), + path(api_settings.PASSWORDLESS_AUTH_PREFIX + 'mobile/', ObtainMobileCallbackToken.as_view(), name='auth_mobile'), + path(api_settings.PASSWORDLESS_AUTH_PREFIX + 'token/', ObtainAuthTokenFromCallbackToken.as_view(), name='auth_token'), + path(api_settings.PASSWORDLESS_VERIFY_PREFIX + 'email/', ObtainEmailVerificationCallbackToken.as_view(), name='verify_email'), + path(api_settings.PASSWORDLESS_VERIFY_PREFIX + 'mobile/', ObtainMobileVerificationCallbackToken.as_view(), name='verify_mobile'), + path(api_settings.PASSWORDLESS_VERIFY_PREFIX, VerifyAliasFromCallbackToken.as_view(), name='verify_token'), ] diff --git a/drfpasswordless/utils.py b/drfpasswordless/utils.py index d1e73ed..2bdc106 100644 --- a/drfpasswordless/utils.py +++ b/drfpasswordless/utils.py @@ -16,7 +16,7 @@ def authenticate_by_token(callback_token): try: - token = CallbackToken.objects.get(key=callback_token, is_active=True) + token = CallbackToken.objects.get(key=callback_token, is_active=True, type=CallbackToken.TOKEN_TYPE_AUTH) # Returning a user designates a successful authentication. token.user = User.objects.get(pk=token.user.pk) @@ -35,20 +35,22 @@ def authenticate_by_token(callback_token): return None -def create_callback_token_for_user(user, token_type): +def create_callback_token_for_user(user, alias_type, token_type): token = None - token_type = token_type.upper() + alias_type_u = alias_type.upper() - if token_type == 'EMAIL': + if alias_type_u == 'EMAIL': token = CallbackToken.objects.create(user=user, - to_alias_type=token_type, - to_alias=getattr(user, api_settings.PASSWORDLESS_USER_EMAIL_FIELD_NAME)) + to_alias_type=alias_type_u, + to_alias=getattr(user, api_settings.PASSWORDLESS_USER_EMAIL_FIELD_NAME), + type=token_type) - elif token_type == 'MOBILE': + elif alias_type_u == 'MOBILE': token = CallbackToken.objects.create(user=user, - to_alias_type=token_type, - to_alias=getattr(user, api_settings.PASSWORDLESS_USER_MOBILE_FIELD_NAME)) + to_alias_type=alias_type_u, + to_alias=getattr(user, api_settings.PASSWORDLESS_USER_MOBILE_FIELD_NAME), + type=token_type) if token is not None: return token diff --git a/drfpasswordless/views.py b/drfpasswordless/views.py index fa38396..500cfbb 100644 --- a/drfpasswordless/views.py +++ b/drfpasswordless/views.py @@ -4,6 +4,7 @@ from rest_framework.response import Response from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.views import APIView +from drfpasswordless.models import CallbackToken from drfpasswordless.settings import api_settings from drfpasswordless.serializers import ( EmailAuthSerializer, @@ -37,6 +38,11 @@ def alias_type(self): # Alias Type raise NotImplementedError + @property + def token_type(self): + # Token Type + raise NotImplementedError + def post(self, request, *args, **kwargs): if self.alias_type.upper() not in api_settings.PASSWORDLESS_AUTH_TYPES: # Only allow auth types allowed in settings. @@ -47,7 +53,7 @@ def post(self, request, *args, **kwargs): # Validate - user = serializer.validated_data['user'] # Create and send callback token - success = TokenService.send_token(user, self.alias_type, **self.message_payload) + success = TokenService.send_token(user, self.alias_type, self.token_type, **self.message_payload) # Respond With Success Or Failure of Sent if success: @@ -68,6 +74,7 @@ class ObtainEmailCallbackToken(AbstractBaseObtainCallbackToken): failure_response = "Unable to email you a login code. Try again later." alias_type = 'email' + token_type = CallbackToken.TOKEN_TYPE_AUTH email_subject = api_settings.PASSWORDLESS_EMAIL_SUBJECT email_plaintext = api_settings.PASSWORDLESS_EMAIL_PLAINTEXT_MESSAGE @@ -84,6 +91,7 @@ class ObtainMobileCallbackToken(AbstractBaseObtainCallbackToken): failure_response = "Unable to send you a login code. Try again later." alias_type = 'mobile' + token_type = CallbackToken.TOKEN_TYPE_AUTH mobile_message = api_settings.PASSWORDLESS_MOBILE_MESSAGE message_payload = {'mobile_message': mobile_message} @@ -96,6 +104,7 @@ class ObtainEmailVerificationCallbackToken(AbstractBaseObtainCallbackToken): failure_response = "Unable to email you a verification code. Try again later." alias_type = 'email' + token_type = CallbackToken.TOKEN_TYPE_VERIFY email_subject = api_settings.PASSWORDLESS_EMAIL_VERIFICATION_SUBJECT email_plaintext = api_settings.PASSWORDLESS_EMAIL_VERIFICATION_PLAINTEXT_MESSAGE @@ -114,6 +123,7 @@ class ObtainMobileVerificationCallbackToken(AbstractBaseObtainCallbackToken): failure_response = "Unable to send you a verification code. Try again later." alias_type = 'mobile' + token_type = CallbackToken.TOKEN_TYPE_VERIFY mobile_message = api_settings.PASSWORDLESS_MOBILE_MESSAGE message_payload = {'mobile_message': mobile_message} diff --git a/tests/test_authentication.py b/tests/test_authentication.py index ecda58f..ca3d44a 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -3,6 +3,7 @@ from rest_framework.test import APITestCase from django.contrib.auth import get_user_model +from django.urls import reverse from drfpasswordless.settings import api_settings, DEFAULTS from drfpasswordless.utils import CallbackToken @@ -15,7 +16,7 @@ def setUp(self): api_settings.PASSWORDLESS_EMAIL_NOREPLY_ADDRESS = 'noreply@example.com' self.email_field_name = api_settings.PASSWORDLESS_USER_EMAIL_FIELD_NAME - self.url = '/auth/email/' + self.url = reverse('drfpasswordless:auth_email') def test_email_signup_failed(self): email = 'failedemail182+' @@ -75,8 +76,8 @@ def setUp(self): api_settings.PASSWORDLESS_EMAIL_NOREPLY_ADDRESS = 'noreply@example.com' self.email = 'aaron@example.com' - self.url = '/auth/email/' - self.challenge_url = '/callback/auth/' + self.url = reverse('drfpasswordless:auth_email') + self.challenge_url = reverse('drfpasswordless:auth_token') self.email_field_name = api_settings.PASSWORDLESS_USER_EMAIL_FIELD_NAME self.user = User.objects.create(**{self.email_field_name: self.email}) @@ -87,7 +88,33 @@ def test_email_auth_failed(self): self.assertEqual(response.status_code, status.HTTP_200_OK) # Token sent to alias - challenge_data = {'token': '123456'} # Send an arbitrary token instead + challenge_data = {'email': self.email, 'token': '123456'} # Send an arbitrary token instead + + # Try to auth with the callback token + challenge_response = self.client.post(self.challenge_url, challenge_data) + self.assertEqual(challenge_response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_email_auth_missing_alias(self): + data = {'email': self.email} + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Token sent to alias + callback_token = CallbackToken.objects.filter(user=self.user, is_active=True).first() + challenge_data = {'token': callback_token} # Missing Alias + + # Try to auth with the callback token + challenge_response = self.client.post(self.challenge_url, challenge_data) + self.assertEqual(challenge_response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_email_auth_bad_alias(self): + data = {'email': self.email} + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Token sent to alias + callback_token = CallbackToken.objects.filter(user=self.user, is_active=True).first() + challenge_data = {'email': 'abcde@example.com', 'token': callback_token} # Bad Alias # Try to auth with the callback token challenge_response = self.client.post(self.challenge_url, challenge_data) @@ -100,7 +127,7 @@ def test_email_auth_expired(self): # Token sent to alias callback_token = CallbackToken.objects.filter(user=self.user, is_active=True).first() - challenge_data = {'token': callback_token} + challenge_data = {'email': self.email, 'token': callback_token} data = {'email': self.email} response = self.client.post(self.url, data) @@ -108,7 +135,7 @@ def test_email_auth_expired(self): # Second token sent to alias second_callback_token = CallbackToken.objects.filter(user=self.user, is_active=True).first() - second_challenge_data = {'token': second_callback_token} + second_challenge_data = {'email': self.email, 'token': second_callback_token} # Try to auth with the old callback token challenge_response = self.client.post(self.challenge_url, challenge_data) @@ -129,7 +156,7 @@ def test_email_auth_success(self): # Token sent to alias callback_token = CallbackToken.objects.filter(user=self.user, is_active=True).first() - challenge_data = {'token': callback_token} + challenge_data = {'email': self.email, 'token': callback_token} # Try to auth with the callback token challenge_response = self.client.post(self.challenge_url, challenge_data) @@ -156,7 +183,7 @@ def setUp(self): api_settings.PASSWORDLESS_TEST_SUPPRESSION = True api_settings.PASSWORDLESS_AUTH_TYPES = ['MOBILE'] api_settings.PASSWORDLESS_MOBILE_NOREPLY_NUMBER = '+15550000000' - self.url = '/auth/mobile/' + self.url = reverse('drfpasswordless:auth_mobile') self.mobile_field_name = api_settings.PASSWORDLESS_USER_MOBILE_FIELD_NAME @@ -227,8 +254,8 @@ def setUp(self): api_settings.PASSWORDLESS_EMAIL_NOREPLY_ADDRESS = 'noreply@example.com' self.email = 'aaron@example.com' - self.url = '/auth/email/' - self.challenge_url = '/callback/auth/' + self.url = reverse('drfpasswordless:auth_email') + self.challenge_url = reverse('drfpasswordless:auth_token') self.email_field_name = api_settings.PASSWORDLESS_USER_EMAIL_FIELD_NAME self.user = User.objects.create(**{self.email_field_name: self.email}) @@ -241,7 +268,7 @@ def test_token_creation_gets_overridden(self): # Token sent to alias callback_token = CallbackToken.objects.filter(user=self.user, is_active=True).first() - challenge_data = {'token': callback_token} + challenge_data = {'email': self.email, 'token': callback_token} # Try to auth with the callback token challenge_response = self.client.post(self.challenge_url, challenge_data) @@ -268,8 +295,8 @@ def setUp(self): api_settings.PASSWORDLESS_MOBILE_NOREPLY_NUMBER = '+15550000000' self.mobile = '+15551234567' - self.url = '/auth/mobile/' - self.challenge_url = '/callback/auth/' + self.url = reverse('drfpasswordless:auth_mobile') + self.challenge_url = reverse('drfpasswordless:auth_token') self.mobile_field_name = api_settings.PASSWORDLESS_USER_MOBILE_FIELD_NAME @@ -281,7 +308,7 @@ def test_mobile_auth_failed(self): self.assertEqual(response.status_code, status.HTTP_200_OK) # Token sent to alias - challenge_data = {'token': '123456'} # Send an arbitrary token instead + challenge_data = {'mobile': self.mobile, 'token': '123456'} # Send an arbitrary token instead # Try to auth with the callback token challenge_response = self.client.post(self.challenge_url, challenge_data) @@ -294,7 +321,7 @@ def test_mobile_auth_expired(self): # Token sent to alias first_callback_token = CallbackToken.objects.filter(user=self.user, is_active=True).first() - first_challenge_data = {'token': first_callback_token} + first_challenge_data = {'mobile': self.mobile, 'token': first_callback_token} data = {'mobile': self.mobile} second_response = self.client.post(self.url, data) @@ -302,7 +329,7 @@ def test_mobile_auth_expired(self): # Second token sent to alias second_callback_token = CallbackToken.objects.filter(user=self.user, is_active=True).first() - second_challenge_data = {'token': second_callback_token} + second_challenge_data = {'mobile': self.mobile, 'token': second_callback_token} # Try to auth with the old callback token challenge_response = self.client.post(self.challenge_url, first_challenge_data) @@ -323,7 +350,7 @@ def test_mobile_auth_success(self): # Token sent to alias callback_token = CallbackToken.objects.filter(user=self.user, is_active=True).first() - challenge_data = {'token': callback_token} + challenge_data = {'mobile': self.mobile, 'token': callback_token} # Try to auth with the callback token challenge_response = self.client.post(self.challenge_url, challenge_data) diff --git a/tests/test_settings.py b/tests/test_settings.py index f62524f..a6799d3 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3,6 +3,7 @@ from rest_framework.test import APITestCase from django.contrib.auth import get_user_model +from django.urls import reverse from drfpasswordless.settings import api_settings, DEFAULTS from drfpasswordless.utils import CallbackToken @@ -19,11 +20,11 @@ def setUp(self): api_settings.PASSWORDLESS_TEST_SUPPRESSION = True self.email = 'aaron@example.com' - self.email_url = '/auth/email/' + self.email_url = reverse('drfpasswordless:auth_email') self.email_data = {'email': self.email} self.mobile = '+15551234567' - self.mobile_url = '/auth/mobile/' + self.mobile_url = reverse('drfpasswordless:auth_mobile') self.mobile_data = {'mobile': self.mobile} def test_email_auth_disabled(self): @@ -89,8 +90,8 @@ def setUp(self): api_settings.PASSWORDLESS_EMAIL_NOREPLY_ADDRESS = 'noreply@example.com' api_settings.PASSWORDLESS_USER_MARK_EMAIL_VERIFIED = True - self.url = '/auth/email/' - self.callback_url = '/callback/auth/' + self.url = reverse('drfpasswordless:auth_email') + self.callback_url = reverse('drfpasswordless:auth_token') self.email_field_name = api_settings.PASSWORDLESS_USER_EMAIL_FIELD_NAME self.email_verified_field_name = api_settings.PASSWORDLESS_USER_EMAIL_VERIFIED_FIELD_NAME @@ -107,7 +108,7 @@ def test_email_unverified_to_verified_and_back(self): # Verify a token exists for the user, sign in and check verified again callback = CallbackToken.objects.filter(user=user, is_active=True).first() - callback_data = {'token': callback} + callback_data = {'email': email, 'token': callback} callback_response = self.client.post(self.callback_url, callback_data) self.assertEqual(callback_response.status_code, status.HTTP_200_OK) @@ -139,8 +140,8 @@ def setUp(self): api_settings.PASSWORDLESS_MOBILE_NOREPLY_NUMBER = '+15550000000' api_settings.PASSWORDLESS_USER_MARK_MOBILE_VERIFIED = True - self.url = '/auth/mobile/' - self.callback_url = '/callback/auth/' + self.url = reverse('drfpasswordless:auth_mobile') + self.callback_url = reverse('drfpasswordless:auth_token') self.mobile_field_name = api_settings.PASSWORDLESS_USER_MOBILE_FIELD_NAME self.mobile_verified_field_name = api_settings.PASSWORDLESS_USER_MOBILE_VERIFIED_FIELD_NAME @@ -157,7 +158,7 @@ def test_mobile_unverified_to_verified_and_back(self): # Verify a token exists for the user, sign in and check verified again callback = CallbackToken.objects.filter(user=user, is_active=True).first() - callback_data = {'token': callback} + callback_data = {'mobile': mobile, 'token': callback} callback_response = self.client.post(self.callback_url, callback_data) self.assertEqual(callback_response.status_code, status.HTTP_200_OK) diff --git a/tests/test_verification.py b/tests/test_verification.py index 37873ed..43a846d 100644 --- a/tests/test_verification.py +++ b/tests/test_verification.py @@ -2,6 +2,7 @@ from rest_framework.authtoken.models import Token from rest_framework.test import APITestCase from django.contrib.auth import get_user_model +from django.urls import reverse from drfpasswordless.settings import api_settings, DEFAULTS from drfpasswordless.utils import CallbackToken @@ -15,10 +16,10 @@ def setUp(self): api_settings.PASSWORDLESS_EMAIL_NOREPLY_ADDRESS = 'noreply@example.com' api_settings.PASSWORDLESS_USER_MARK_EMAIL_VERIFIED = True - self.url = '/auth/email/' - self.callback_url = '/callback/auth/' - self.verify_url = '/verify/email/' - self.callback_verify = '/callback/verify/' + self.url = reverse('drfpasswordless:auth_email') + self.callback_url = reverse('drfpasswordless:auth_token') + self.verify_url = reverse('drfpasswordless:verify_email') + self.callback_verify = reverse('drfpasswordless:verify_token') self.email_field_name = api_settings.PASSWORDLESS_USER_EMAIL_FIELD_NAME self.email_verified_field_name = api_settings.PASSWORDLESS_USER_EMAIL_VERIFIED_FIELD_NAME @@ -35,8 +36,8 @@ def test_email_unverified_to_verified_and_back(self): self.assertEqual(getattr(user, self.email_verified_field_name), False) # Verify a token exists for the user, sign in and check verified again - callback = CallbackToken.objects.filter(user=user, is_active=True).first() - callback_data = {'token': callback} + callback = CallbackToken.objects.filter(user=user, type=CallbackToken.TOKEN_TYPE_AUTH, is_active=True).first() + callback_data = {'email': email, 'token': callback} callback_response = self.client.post(self.callback_url, callback_data) self.assertEqual(callback_response.status_code, status.HTTP_200_OK) @@ -66,8 +67,9 @@ def test_email_unverified_to_verified_and_back(self): self.assertEqual(getattr(user, self.email_verified_field_name), False) # Post callback token back. - verify_token = CallbackToken.objects.filter(user=user, is_active=True).first() - verify_callback_response = self.client.post(self.callback_verify, {'token': verify_token.key}) + verify_token = CallbackToken.objects.filter(user=user, type=CallbackToken.TOKEN_TYPE_VERIFY, is_active=True).first() + self.assertNotEqual(verify_token, None) + verify_callback_response = self.client.post(self.callback_verify, {'email': email2, 'token': verify_token.key}) self.assertEqual(verify_callback_response.status_code, status.HTTP_200_OK) # Refresh User @@ -90,10 +92,10 @@ def setUp(self): api_settings.PASSWORDLESS_MOBILE_NOREPLY_NUMBER = '+15550000000' api_settings.PASSWORDLESS_USER_MARK_MOBILE_VERIFIED = True - self.url = '/auth/mobile/' - self.callback_url = '/callback/auth/' - self.verify_url = '/verify/mobile/' - self.callback_verify = '/callback/verify/' + self.url = reverse('drfpasswordless:auth_mobile') + self.callback_url = reverse('drfpasswordless:auth_token') + self.verify_url = reverse('drfpasswordless:verify_mobile') + self.callback_verify = reverse('drfpasswordless:verify_token') self.mobile_field_name = api_settings.PASSWORDLESS_USER_MOBILE_FIELD_NAME self.mobile_verified_field_name = api_settings.PASSWORDLESS_USER_MOBILE_VERIFIED_FIELD_NAME @@ -110,8 +112,8 @@ def test_mobile_unverified_to_verified_and_back(self): self.assertEqual(getattr(user, self.mobile_verified_field_name), False) # Verify a token exists for the user, sign in and check verified again - callback = CallbackToken.objects.filter(user=user, is_active=True).first() - callback_data = {'token': callback} + callback = CallbackToken.objects.filter(user=user, type=CallbackToken.TOKEN_TYPE_AUTH, is_active=True).first() + callback_data = {'mobile': mobile, 'token': callback} callback_response = self.client.post(self.callback_url, callback_data) self.assertEqual(callback_response.status_code, status.HTTP_200_OK) @@ -141,8 +143,9 @@ def test_mobile_unverified_to_verified_and_back(self): self.assertEqual(getattr(user, self.mobile_verified_field_name), False) # Post callback token back. - verify_token = CallbackToken.objects.filter(user=user, is_active=True).first() - verify_callback_response = self.client.post(self.callback_verify, {'token': verify_token.key}) + verify_token = CallbackToken.objects.filter(user=user, type=CallbackToken.TOKEN_TYPE_VERIFY, is_active=True).first() + self.assertNotEqual(verify_token, None) + verify_callback_response = self.client.post(self.callback_verify, {'mobile': mobile2, 'token': verify_token.key}) self.assertEqual(verify_callback_response.status_code, status.HTTP_200_OK) # Refresh User diff --git a/tests/urls.py b/tests/urls.py index f7020d1..120fea0 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,5 +1,6 @@ -from django.conf.urls import url +from django.urls import path, include from rest_framework.urlpatterns import format_suffix_patterns +from drfpasswordless.settings import api_settings from drfpasswordless.views import (ObtainEmailCallbackToken, ObtainMobileCallbackToken, ObtainAuthTokenFromCallbackToken, @@ -7,11 +8,10 @@ ObtainEmailVerificationCallbackToken, ObtainMobileVerificationCallbackToken, ) -urlpatterns = [url(r'^callback/auth/$', ObtainAuthTokenFromCallbackToken.as_view(), name='auth_callback'), - url(r'^auth/email/$', ObtainEmailCallbackToken.as_view(), name='auth_email'), - url(r'^auth/mobile/$', ObtainMobileCallbackToken.as_view(), name='auth_mobile'), - url(r'^callback/verify/$', VerifyAliasFromCallbackToken.as_view(), name='verify_callback'), - url(r'^verify/email/$', ObtainEmailVerificationCallbackToken.as_view(), name='verify_email'), - url(r'^verify/mobile/$', ObtainMobileVerificationCallbackToken.as_view(), name='verify_mobile')] +app_name = 'drfpasswordless' + +urlpatterns = [ + path('', include('drfpasswordless.urls')), +] format_suffix_patterns(urlpatterns)