Skip to content

Commit a49eb8c

Browse files
authoredJan 24, 2020
Merge pull request aaronn#41 from aaronn/new-alias-endpoints
1.5.0 New Alias Routes & Token Type Validation
2 parents f7ac17d + 7339bcd commit a49eb8c

17 files changed

+217
-116
lines changed
 

‎README.md

+12-14
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ curl -X POST -d "mobile=+15552143912" localhost:8000/auth/mobile/
125125
TokenAuthentication scheme.
126126
127127
```bash
128-
curl -X POST -d "token=815381" localhost:8000/callback/auth/
128+
curl -X POST -d "email=aaron@email.com&token=815381" localhost:8000/auth/token/
129129
130130
> HTTP/1.0 200 OK
131131
> {"token":"76be2d9ecfaf5fa4226d722bzdd8a4fff207ed0e”}
@@ -149,19 +149,11 @@ You’ll also need to set up an SMTP server to send emails (`See Django
149149
Docs <https://docs.djangoproject.com/en/1.10/topics/email/>`__), but for
150150
development you can set up a dummy development smtp server to test
151151
emails. Sent emails will print to the console. `Read more
152-
here. <https://docs.djangoproject.com/en/1.10/topics/email/#configuring-email-for-development>`__
152+
here. <https://docs.djangoproject.com/en/3.0/topics/email/#console-backend>`__
153153
154154
```python
155155
# Settings.py
156-
157-
EMAIL_HOST = 'localhost'
158-
EMAIL_PORT = 1025
159-
```
160-
161-
Then run the following:
162-
163-
```bash
164-
python -m smtpd -n -c DebuggingServer localhost:1025
156+
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
165157
```
166158
167159
Configuring Mobile
@@ -210,11 +202,11 @@ This is off by default but can be turned on with
210202
enabled they look for the User model fields ``email_verified`` or
211203
``mobile_verified``.
212204
213-
You can also use ``/validate/email/`` or ``/validate/mobile/`` which will
205+
You can also use ``auth/verify/email/`` or ``/auth/verify/mobile/`` which will
214206
automatically send a token to the endpoint attached to the current
215207
``request.user``'s email or mobile if available.
216208
217-
You can then send that token to ``/callback/verify/`` which will double-check
209+
You can then send that token to ``/auth/verify/`` which will double-check
218210
that the endpoint belongs to the request.user and mark the alias as verified.
219211
220212
Registration
@@ -239,6 +231,12 @@ DEFAULTS = {
239231
# Allowed auth types, can be EMAIL, MOBILE, or both.
240232
'PASSWORDLESS_AUTH_TYPES': ['EMAIL'],
241233
234+
# URL Prefix for Authentication Endpoints
235+
'PASSWORDLESS_AUTH_PREFIX': 'auth',
236+
237+
# URL Prefix for Verification Endpoints
238+
'PASSWORDLESS_VERIFY_PREFIX': 'auth',
239+
242240
# Amount of time that tokens last, in seconds
243241
'PASSWORDLESS_TOKEN_EXPIRE_TIME': 15 * 60,
244242
@@ -338,7 +336,7 @@ License
338336
339337
The MIT License (MIT)
340338
341-
Copyright (c) 2018 Aaron Ng
339+
Copyright (c) 2020 Aaron Ng
342340
343341
Permission is hereby granted, free of charge, to any person obtaining a copy
344342
of this software and associated documentation files (the "Software"), to deal

‎drfpasswordless/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22

33
__title__ = 'drfpasswordless'
4-
__version__ = '1.4.0'
4+
__version__ = '1.5.0'
55
__author__ = 'Aaron Ng'
66
__license__ = 'MIT'
77
__copyright__ = 'Copyright 2020 Aaron Ng'

‎drfpasswordless/__version__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
VERSION = (1, 4, 0)
1+
VERSION = (1, 5, 0)
22

33
__version__ = '.'.join(map(str, VERSION))

‎drfpasswordless/admin.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,16 @@ def link_to_user(self, obj):
1919
class AbstractCallbackTokenInline(admin.StackedInline):
2020
max_num = 0
2121
extra = 0
22-
readonly_fields = ('created_at', 'key', 'is_active')
23-
fields = ('created_at', 'user', 'key', 'is_active')
22+
readonly_fields = ('created_at', 'key', 'type', 'is_active')
23+
fields = ('created_at', 'user', 'key', 'type', 'is_active')
2424

2525

2626
class CallbackInline(AbstractCallbackTokenInline):
2727
model = CallbackToken
2828

2929

3030
class AbstractCallbackTokenAdmin(UserLinkMixin, admin.ModelAdmin):
31-
readonly_fields = ('created_at', 'user', 'key')
32-
list_display = ('created_at', UserLinkMixin.LINK_TO_USER_FIELD, 'key', 'is_active')
33-
fields = ('created_at', 'user', 'key', 'is_active')
31+
readonly_fields = ('created_at', 'user', 'key', 'type',)
32+
list_display = ('created_at', UserLinkMixin.LINK_TO_USER_FIELD, 'key', 'type', 'is_active')
33+
fields = ('created_at', 'user', 'key', 'type', 'is_active')
3434
extra = 0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 3.0.2 on 2020-01-22 08:34
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('drfpasswordless', '0002_auto_20200122_0424'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='callbacktoken',
15+
name='type',
16+
field=models.CharField(choices=[('AUTH', 'Auth'), ('VERIFY', 'Verify')], default='VERIFY', max_length=20),
17+
preserve_default=False,
18+
),
19+
]

‎drfpasswordless/models.py

+6
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class AbstractBaseCallbackToken(models.Model):
3333
When a new token is created, older ones of the same type are invalidated
3434
via the pre_save signal in signals.py.
3535
"""
36+
3637
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
3738
created_at = models.DateTimeField(auto_now_add=True)
3839
user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name=None, on_delete=models.CASCADE)
@@ -56,7 +57,12 @@ class CallbackToken(AbstractBaseCallbackToken):
5657
"""
5758
Generates a random six digit number to be returned.
5859
"""
60+
TOKEN_TYPE_AUTH = 'AUTH'
61+
TOKEN_TYPE_VERIFY = 'VERIFY'
62+
TOKEN_TYPES = ((TOKEN_TYPE_AUTH, 'Auth'), (TOKEN_TYPE_VERIFY, 'Verify'))
63+
5964
key = models.CharField(default=generate_numeric_token, max_length=6, unique=True)
65+
type = models.CharField(max_length=20, choices=TOKEN_TYPES)
6066

6167
class Meta(AbstractBaseCallbackToken.Meta):
6268
verbose_name = 'Callback Token'

‎drfpasswordless/serializers.py

+49-16
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.core.exceptions import PermissionDenied
55
from django.core.validators import RegexValidator
66
from rest_framework import serializers
7+
from rest_framework.exceptions import ValidationError
78
from drfpasswordless.models import CallbackToken
89
from drfpasswordless.settings import api_settings
910
from drfpasswordless.utils import authenticate_by_token, verify_user_alias, validate_token_age
@@ -168,21 +169,48 @@ class AbstractBaseCallbackTokenSerializer(serializers.Serializer):
168169
Abstract class inspired by DRF's own token serializer.
169170
Returns a user if valid, None or a message if not.
170171
"""
172+
phone_regex = RegexValidator(regex=r'^\+?1?\d{9,15}$',
173+
message="Mobile number must be entered in the format:"
174+
" '+999999999'. Up to 15 digits allowed.")
175+
176+
email = serializers.EmailField(required=False) # Needs to be required=false to require both.
177+
mobile = serializers.CharField(required=False, validators=[phone_regex], max_length=15)
171178
token = TokenField(min_length=6, max_length=6, validators=[token_age_validator])
172179

180+
def validate_alias(self, attrs):
181+
email = attrs.get('email', None)
182+
mobile = attrs.get('mobile', None)
183+
184+
if email and mobile:
185+
raise serializers.ValidationError()
186+
187+
if not email and not mobile:
188+
raise serializers.ValidationError()
189+
190+
if email:
191+
return 'email', email
192+
elif mobile:
193+
return 'mobile', mobile
194+
195+
return None
196+
173197

174198
class CallbackTokenAuthSerializer(AbstractBaseCallbackTokenSerializer):
175199

176200
def validate(self, attrs):
177-
callback_token = attrs.get('token', None)
178-
179-
token = CallbackToken.objects.get(key=callback_token, is_active=True)
201+
# Check Aliases
202+
try:
203+
alias_type, alias = self.validate_alias(attrs)
204+
callback_token = attrs.get('token', None)
205+
user = User.objects.get(**{alias_type: alias})
206+
token = CallbackToken.objects.get(**{'user': user,
207+
'key': callback_token,
208+
'type': CallbackToken.TOKEN_TYPE_AUTH,
209+
'is_active': True})
180210

181-
if token:
182-
# Check the token type for our uni-auth method.
183-
# authenticates and checks the expiry of the callback token.
184-
user = authenticate_by_token(token)
185-
if user:
211+
if token.user == user:
212+
# Check the token type for our uni-auth method.
213+
# authenticates and checks the expiry of the callback token.
186214
if not user.is_active:
187215
msg = _('User account is disabled.')
188216
raise serializers.ValidationError(msg)
@@ -203,8 +231,11 @@ def validate(self, attrs):
203231
else:
204232
msg = _('Invalid Token')
205233
raise serializers.ValidationError(msg)
206-
else:
207-
msg = _('Missing authentication token.')
234+
except User.DoesNotExist:
235+
msg = _('Invalid alias parameters provided.')
236+
raise serializers.ValidationError(msg)
237+
except ValidationError:
238+
msg = _('Invalid alias parameters provided.')
208239
raise serializers.ValidationError(msg)
209240

210241

@@ -216,15 +247,17 @@ class CallbackTokenVerificationSerializer(AbstractBaseCallbackTokenSerializer):
216247

217248
def validate(self, attrs):
218249
try:
250+
alias_type, alias = self.validate_alias(attrs)
219251
user_id = self.context.get("user_id")
252+
user = User.objects.get(**{'id': user_id, alias_type: alias})
220253
callback_token = attrs.get('token', None)
221254

222-
token = CallbackToken.objects.get(key=callback_token, is_active=True)
223-
user = User.objects.get(pk=user_id)
255+
token = CallbackToken.objects.get(**{'user': user,
256+
'key': callback_token,
257+
'type': CallbackToken.TOKEN_TYPE_VERIFY,
258+
'is_active': True})
224259

225260
if token.user == user:
226-
# Check that the token.user is the request.user
227-
228261
# Mark this alias as verified
229262
success = verify_user_alias(user, token)
230263
if success is False:
@@ -237,11 +270,11 @@ def validate(self, attrs):
237270
logger.debug("drfpasswordless: User token mismatch when verifying alias.")
238271

239272
except CallbackToken.DoesNotExist:
240-
msg = _('Missing authentication token.')
273+
msg = _('We could not verify this alias.')
241274
logger.debug("drfpasswordless: Tried to validate alias with bad token.")
242275
pass
243276
except User.DoesNotExist:
244-
msg = _('Missing user.')
277+
msg = _('We could not verify this alias.')
245278
logger.debug("drfpasswordless: Tried to validate alias with bad user.")
246279
pass
247280
except PermissionDenied:

‎drfpasswordless/services.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77

88
class TokenService(object):
99
@staticmethod
10-
def send_token(user, alias_type, **message_payload):
11-
token = create_callback_token_for_user(user, alias_type)
10+
def send_token(user, alias_type, token_type, **message_payload):
11+
token = create_callback_token_for_user(user, alias_type, token_type)
1212
send_action = None
1313
if alias_type == 'email':
1414
send_action = send_email_with_callback_token

‎drfpasswordless/settings.py

+6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
# Allowed auth types, can be EMAIL, MOBILE, or both.
99
'PASSWORDLESS_AUTH_TYPES': ['EMAIL'],
1010

11+
# URL Prefix for Authentication Endpoints
12+
'PASSWORDLESS_AUTH_PREFIX': 'auth/',
13+
14+
# URL Prefix for Verification Endpoints
15+
'PASSWORDLESS_VERIFY_PREFIX': 'auth/verify/',
16+
1117
# Amount of time that tokens last, in seconds
1218
'PASSWORDLESS_TOKEN_EXPIRE_TIME': 15 * 60,
1319

‎drfpasswordless/signals.py

+6-13
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,13 @@
1010
logger = logging.getLogger(__name__)
1111

1212

13-
@receiver(signals.pre_save, sender=CallbackToken)
14-
def invalidate_previous_tokens(sender, instance, **kwargs):
13+
@receiver(signals.post_save, sender=CallbackToken)
14+
def invalidate_previous_tokens(sender, instance, created, **kwargs):
1515
"""
16-
Invalidates all previously issued tokens as a post_save signal.
16+
Invalidates all previously issued tokens of that type when a new one is created, used, or anything like that.
1717
"""
18-
active_tokens = None
1918
if isinstance(instance, CallbackToken):
20-
active_tokens = CallbackToken.objects.active().filter(user=instance.user).exclude(id=instance.id)
21-
22-
# Invalidate tokens
23-
if active_tokens:
24-
for token in active_tokens:
25-
token.is_active = False
26-
token.save()
19+
CallbackToken.objects.active().filter(user=instance.user, type=instance.type).exclude(id=instance.id).update(is_active=False)
2720

2821

2922
@receiver(signals.pre_save, sender=CallbackToken)
@@ -72,7 +65,7 @@ def update_alias_verification(sender, instance, **kwargs):
7265
message_payload = {'email_subject': email_subject,
7366
'email_plaintext': email_plaintext,
7467
'email_html': email_html}
75-
success = TokenService.send_token(instance, 'email', **message_payload)
68+
success = TokenService.send_token(instance, 'email', CallbackToken.TOKEN_TYPE_VERIFY, **message_payload)
7669

7770
if success:
7871
logger.info('drfpasswordless: Successfully sent email on updated address: %s'
@@ -104,7 +97,7 @@ def update_alias_verification(sender, instance, **kwargs):
10497
if api_settings.PASSWORDLESS_AUTO_SEND_VERIFICATION_TOKEN is True:
10598
mobile_message = api_settings.PASSWORDLESS_MOBILE_MESSAGE
10699
message_payload = {'mobile_message': mobile_message}
107-
success = TokenService.send_token(instance, 'mobile', **message_payload)
100+
success = TokenService.send_token(instance, 'mobile', CallbackToken.TOKEN_TYPE_VERIFY, **message_payload)
108101

109102
if success:
110103
logger.info('drfpasswordless: Successfully sent SMS on updated mobile: %s'

‎drfpasswordless/urls.py

+9-6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from drfpasswordless.settings import api_settings
12
from django.urls import path
23
from drfpasswordless.views import (
34
ObtainEmailCallbackToken,
@@ -8,11 +9,13 @@
89
ObtainMobileVerificationCallbackToken,
910
)
1011

12+
app_name = 'drfpasswordless'
13+
1114
urlpatterns = [
12-
path('callback/auth/', ObtainAuthTokenFromCallbackToken.as_view(), name='auth_callback'),
13-
path('auth/email/', ObtainEmailCallbackToken.as_view(), name='auth_email'),
14-
path('auth/mobile/', ObtainMobileCallbackToken.as_view(), name='auth_mobile'),
15-
path('callback/verify/', VerifyAliasFromCallbackToken.as_view(), name='verify_callback'),
16-
path('verify/email/', ObtainEmailVerificationCallbackToken.as_view(), name='verify_email'),
17-
path('verify/mobile/', ObtainMobileVerificationCallbackToken.as_view(), name='verify_mobile'),
15+
path(api_settings.PASSWORDLESS_AUTH_PREFIX + 'email/', ObtainEmailCallbackToken.as_view(), name='auth_email'),
16+
path(api_settings.PASSWORDLESS_AUTH_PREFIX + 'mobile/', ObtainMobileCallbackToken.as_view(), name='auth_mobile'),
17+
path(api_settings.PASSWORDLESS_AUTH_PREFIX + 'token/', ObtainAuthTokenFromCallbackToken.as_view(), name='auth_token'),
18+
path(api_settings.PASSWORDLESS_VERIFY_PREFIX + 'email/', ObtainEmailVerificationCallbackToken.as_view(), name='verify_email'),
19+
path(api_settings.PASSWORDLESS_VERIFY_PREFIX + 'mobile/', ObtainMobileVerificationCallbackToken.as_view(), name='verify_mobile'),
20+
path(api_settings.PASSWORDLESS_VERIFY_PREFIX, VerifyAliasFromCallbackToken.as_view(), name='verify_token'),
1821
]

‎drfpasswordless/utils.py

+11-9
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
def authenticate_by_token(callback_token):
1818
try:
19-
token = CallbackToken.objects.get(key=callback_token, is_active=True)
19+
token = CallbackToken.objects.get(key=callback_token, is_active=True, type=CallbackToken.TOKEN_TYPE_AUTH)
2020

2121
# Returning a user designates a successful authentication.
2222
token.user = User.objects.get(pk=token.user.pk)
@@ -35,20 +35,22 @@ def authenticate_by_token(callback_token):
3535
return None
3636

3737

38-
def create_callback_token_for_user(user, token_type):
38+
def create_callback_token_for_user(user, alias_type, token_type):
3939

4040
token = None
41-
token_type = token_type.upper()
41+
alias_type_u = alias_type.upper()
4242

43-
if token_type == 'EMAIL':
43+
if alias_type_u == 'EMAIL':
4444
token = CallbackToken.objects.create(user=user,
45-
to_alias_type=token_type,
46-
to_alias=getattr(user, api_settings.PASSWORDLESS_USER_EMAIL_FIELD_NAME))
45+
to_alias_type=alias_type_u,
46+
to_alias=getattr(user, api_settings.PASSWORDLESS_USER_EMAIL_FIELD_NAME),
47+
type=token_type)
4748

48-
elif token_type == 'MOBILE':
49+
elif alias_type_u == 'MOBILE':
4950
token = CallbackToken.objects.create(user=user,
50-
to_alias_type=token_type,
51-
to_alias=getattr(user, api_settings.PASSWORDLESS_USER_MOBILE_FIELD_NAME))
51+
to_alias_type=alias_type_u,
52+
to_alias=getattr(user, api_settings.PASSWORDLESS_USER_MOBILE_FIELD_NAME),
53+
type=token_type)
5254

5355
if token is not None:
5456
return token

0 commit comments

Comments
 (0)
Please sign in to comment.