Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
a6dc006
New alias endpoints initial
aaronn Jan 22, 2020
20146f2
Fix send_token calls
aaronn Jan 22, 2020
e53fc5e
Include token_type in send_token
aaronn Jan 22, 2020
2f2f089
Fix urls
aaronn Jan 22, 2020
5775f5e
Fix #30 infinite recursion on save signal
aaronn Jan 22, 2020
bb18e0d
Customizable URL paths
aaronn Jan 24, 2020
0426d39
Tests for custom urls
aaronn Jan 24, 2020
fdb6770
Fix tests for urls
aaronn Jan 24, 2020
e69cbc6
Fix tests for urls
aaronn Jan 24, 2020
8750a9f
Try namespacing fix url tests
aaronn Jan 24, 2020
e59fc48
Fix tests
aaronn Jan 24, 2020
4f0f52e
Namespace in urls
aaronn Jan 24, 2020
bb482b9
urls
aaronn Jan 24, 2020
48c5d27
Add generic token and verify endpoints
aaronn Jan 24, 2020
32107c0
Fix serializer logic
aaronn Jan 24, 2020
79ed09e
Tests
aaronn Jan 24, 2020
e8df3a4
Tests
aaronn Jan 24, 2020
d14970c
tests
aaronn Jan 24, 2020
a5a1bc7
Tests
aaronn Jan 24, 2020
572eb34
Tests
aaronn Jan 24, 2020
a031fc4
Tests
aaronn Jan 24, 2020
d25d8a3
Tests
aaronn Jan 24, 2020
e44dcef
Test
aaronn Jan 24, 2020
ec1d781
Tests
aaronn Jan 24, 2020
b98f1ac
Tests
aaronn Jan 24, 2020
ed91f8e
Tests
aaronn Jan 24, 2020
e511e42
Tests
aaronn Jan 24, 2020
903d824
Tests
aaronn Jan 24, 2020
1414840
Tests
aaronn Jan 24, 2020
e71231c
Tests
aaronn Jan 24, 2020
c662b3c
tests
aaronn Jan 24, 2020
f441200
Tests
aaronn Jan 24, 2020
bc1dbbe
Tests
aaronn Jan 24, 2020
9de8327
Tests
aaronn Jan 24, 2020
a0d41bc
Tests
aaronn Jan 24, 2020
75fa745
Tests
aaronn Jan 24, 2020
52b3116
Tests
aaronn Jan 24, 2020
d29dc79
Tests
aaronn Jan 24, 2020
6c7b53b
Tests
aaronn Jan 24, 2020
7339bcd
Tests
aaronn Jan 24, 2020
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
26 changes: 12 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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”}
Expand All @@ -149,19 +149,11 @@ You’ll also need to set up an SMTP server to send emails (`See Django
Docs <https://docs.djangoproject.com/en/1.10/topics/email/>`__), 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. <https://docs.djangoproject.com/en/1.10/topics/email/#configuring-email-for-development>`__
here. <https://docs.djangoproject.com/en/3.0/topics/email/#console-backend>`__

```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
Expand Down Expand Up @@ -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
Expand All @@ -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,

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion drfpasswordless/__init__.py
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 1 addition & 1 deletion drfpasswordless/__version__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
VERSION = (1, 4, 0)
VERSION = (1, 5, 0)

__version__ = '.'.join(map(str, VERSION))
10 changes: 5 additions & 5 deletions drfpasswordless/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ 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):
model = CallbackToken


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
19 changes: 19 additions & 0 deletions drfpasswordless/migrations/0003_callbacktoken_type.py
Original file line number Diff line number Diff line change
@@ -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,
),
]
6 changes: 6 additions & 0 deletions drfpasswordless/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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'
65 changes: 49 additions & 16 deletions drfpasswordless/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)


Expand All @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions drfpasswordless/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions drfpasswordless/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
19 changes: 6 additions & 13 deletions drfpasswordless/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
15 changes: 9 additions & 6 deletions drfpasswordless/urls.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from drfpasswordless.settings import api_settings
from django.urls import path
from drfpasswordless.views import (
ObtainEmailCallbackToken,
Expand All @@ -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'),
]
20 changes: 11 additions & 9 deletions drfpasswordless/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading