From ee6c8f2287ff3aea2f3d2c5ced9b3f4409faf8fb Mon Sep 17 00:00:00 2001 From: Nishant Singh Date: Thu, 18 Mar 2021 16:39:30 +0530 Subject: [PATCH 01/18] Configure token length, max length 6 --- README.md | 2 ++ drfpasswordless/models.py | 3 ++- drfpasswordless/serializers.py | 2 +- drfpasswordless/settings.py | 5 ++++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6fe49a7..329dffd 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,8 @@ DEFAULTS = { # Token Generation Retry Count 'PASSWORDLESS_TOKEN_GENERATION_ATTEMPTS': 3 + # The length of the token to send in email or sms, maximum 6 + 'PASSWORDLESS_TOKEN_LENGTH': 6 } ``` diff --git a/drfpasswordless/models.py b/drfpasswordless/models.py index d09f3fc..b07b16f 100644 --- a/drfpasswordless/models.py +++ b/drfpasswordless/models.py @@ -3,6 +3,7 @@ from django.conf import settings import string from django.utils.crypto import get_random_string +from drfpasswordless.settings import api_settings def generate_hex_token(): return uuid.uuid1().hex @@ -13,7 +14,7 @@ def generate_numeric_token(): Generate a random 6 digit string of numbers. We use this formatting to allow leading 0s. """ - return get_random_string(length=6, allowed_chars=string.digits) + return get_random_string(length=api_settings.PASSWORDLESS_TOKEN_LENGTH, allowed_chars=string.digits) class CallbackTokenManger(models.Manager): diff --git a/drfpasswordless/serializers.py b/drfpasswordless/serializers.py index 6ce1523..1ed61bc 100644 --- a/drfpasswordless/serializers.py +++ b/drfpasswordless/serializers.py @@ -175,7 +175,7 @@ class AbstractBaseCallbackTokenSerializer(serializers.Serializer): email = serializers.EmailField(required=False) # Needs to be required=false to require both. mobile = serializers.CharField(required=False, validators=[phone_regex], max_length=17) - token = TokenField(min_length=6, max_length=6, validators=[token_age_validator]) + token = TokenField(min_length=api_settings.PASSWORDLESS_TOKEN_LENGTH, max_length=api_settings.PASSWORDLESS_TOKEN_LENGTH, validators=[token_age_validator]) def validate_alias(self, attrs): email = attrs.get('email', None) diff --git a/drfpasswordless/settings.py b/drfpasswordless/settings.py index 5b93197..c9bd842 100644 --- a/drfpasswordless/settings.py +++ b/drfpasswordless/settings.py @@ -89,7 +89,10 @@ 'PASSWORDLESS_SMS_CALLBACK': 'drfpasswordless.utils.send_sms_with_callback_token', # Token Generation Retry Count - 'PASSWORDLESS_TOKEN_GENERATION_ATTEMPTS': 3 + 'PASSWORDLESS_TOKEN_GENERATION_ATTEMPTS': 3, + + # The length of the token to send in email or sms, maximum 6 + 'PASSWORDLESS_TOKEN_LENGTH': 6 } # List of settings that may be in string import notation. From 34ac637a6450f02c86ea2ea9ecec82a7a05f4d71 Mon Sep 17 00:00:00 2001 From: Naveen Panwar Date: Wed, 17 Aug 2022 12:09:46 +0530 Subject: [PATCH 02/18] fix: Fixing codestyle changes Fixing codestyle changes to match PEP8 --- README.md | 2 +- drfpasswordless/models.py | 5 ++++- drfpasswordless/serializers.py | 6 +++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3e810e9..69b1848 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,7 @@ DEFAULTS = { 'PASSWORDLESS_SMS_CALLBACK': 'drfpasswordless.utils.send_sms_with_callback_token', # Token Generation Retry Count - 'PASSWORDLESS_TOKEN_GENERATION_ATTEMPTS': 3 + 'PASSWORDLESS_TOKEN_GENERATION_ATTEMPTS': 3, # The length of the token to send in email or sms, maximum 6 'PASSWORDLESS_TOKEN_LENGTH': 6 diff --git a/drfpasswordless/models.py b/drfpasswordless/models.py index b07b16f..6d3b677 100644 --- a/drfpasswordless/models.py +++ b/drfpasswordless/models.py @@ -14,7 +14,10 @@ def generate_numeric_token(): Generate a random 6 digit string of numbers. We use this formatting to allow leading 0s. """ - return get_random_string(length=api_settings.PASSWORDLESS_TOKEN_LENGTH, allowed_chars=string.digits) + return get_random_string( + length=api_settings.PASSWORDLESS_TOKEN_LENGTH, + allowed_chars=string.digits + ) class CallbackTokenManger(models.Manager): diff --git a/drfpasswordless/serializers.py b/drfpasswordless/serializers.py index f2476d2..478ec8b 100644 --- a/drfpasswordless/serializers.py +++ b/drfpasswordless/serializers.py @@ -175,7 +175,11 @@ class AbstractBaseCallbackTokenSerializer(serializers.Serializer): email = serializers.EmailField(required=False) # Needs to be required=false to require both. mobile = serializers.CharField(required=False, validators=[phone_regex], max_length=17) - token = TokenField(min_length=api_settings.PASSWORDLESS_TOKEN_LENGTH, max_length=api_settings.PASSWORDLESS_TOKEN_LENGTH, validators=[token_age_validator]) + token = TokenField( + min_length=api_settings.PASSWORDLESS_TOKEN_LENGTH, + max_length=api_settings.PASSWORDLESS_TOKEN_LENGTH, + validators=[token_age_validator] + ) def validate_alias(self, attrs): email = attrs.get('email', None) From 3eba9ae453bd2d29983067f0d2ac42a274713081 Mon Sep 17 00:00:00 2001 From: anish Date: Mon, 30 Sep 2024 06:34:24 +0530 Subject: [PATCH 03/18] Added Auth token support --- drfpasswordless/auth.py | 6 ++ drfpasswordless/authtoken/__init__.py | 4 ++ drfpasswordless/authtoken/admin.py | 51 ++++++++++++++ drfpasswordless/authtoken/apps.py | 7 ++ .../authtoken/management/__init__.py | 0 .../authtoken/management/commands/__init__.py | 0 .../management/commands/drf_create_token.py | 45 ++++++++++++ .../authtoken/migrations/0001_initial.py | 23 ++++++ .../migrations/0002_auto_20160226_1747.py | 31 ++++++++ .../authtoken/migrations/0003_tokenproxy.py | 25 +++++++ .../migrations/0004_auto_20240927_1224.py | 31 ++++++++ .../authtoken/migrations/__init__.py | 0 drfpasswordless/authtoken/models.py | 70 +++++++++++++++++++ drfpasswordless/authtoken/serializers.py | 42 +++++++++++ drfpasswordless/authtoken/views.py | 62 ++++++++++++++++ 15 files changed, 397 insertions(+) create mode 100644 drfpasswordless/auth.py create mode 100644 drfpasswordless/authtoken/__init__.py create mode 100644 drfpasswordless/authtoken/admin.py create mode 100644 drfpasswordless/authtoken/apps.py create mode 100644 drfpasswordless/authtoken/management/__init__.py create mode 100644 drfpasswordless/authtoken/management/commands/__init__.py create mode 100644 drfpasswordless/authtoken/management/commands/drf_create_token.py create mode 100644 drfpasswordless/authtoken/migrations/0001_initial.py create mode 100644 drfpasswordless/authtoken/migrations/0002_auto_20160226_1747.py create mode 100644 drfpasswordless/authtoken/migrations/0003_tokenproxy.py create mode 100644 drfpasswordless/authtoken/migrations/0004_auto_20240927_1224.py create mode 100644 drfpasswordless/authtoken/migrations/__init__.py create mode 100644 drfpasswordless/authtoken/models.py create mode 100644 drfpasswordless/authtoken/serializers.py create mode 100644 drfpasswordless/authtoken/views.py diff --git a/drfpasswordless/auth.py b/drfpasswordless/auth.py new file mode 100644 index 0000000..ace62fd --- /dev/null +++ b/drfpasswordless/auth.py @@ -0,0 +1,6 @@ +from rest_framework.authentication import TokenAuthentication +from drfpasswordless.authtoken.models import Token + + +class TokenAuthentication(TokenAuthentication): + model = Token \ No newline at end of file diff --git a/drfpasswordless/authtoken/__init__.py b/drfpasswordless/authtoken/__init__.py new file mode 100644 index 0000000..7c33df6 --- /dev/null +++ b/drfpasswordless/authtoken/__init__.py @@ -0,0 +1,4 @@ +import django + +if django.VERSION < (3, 2): + default_app_config = 'drfpasswordless.authtoken.apps.AuthTokenConfig' diff --git a/drfpasswordless/authtoken/admin.py b/drfpasswordless/authtoken/admin.py new file mode 100644 index 0000000..f24a07f --- /dev/null +++ b/drfpasswordless/authtoken/admin.py @@ -0,0 +1,51 @@ +from django.contrib import admin +from django.contrib.admin.utils import quote +from django.contrib.admin.views.main import ChangeList +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.urls import reverse + +from drfpasswordless.authtoken.models import Token, TokenProxy + +User = get_user_model() + + +class TokenChangeList(ChangeList): + """Map to matching User id""" + def url_for_result(self, result): + pk = result.user.pk + return reverse('admin:%s_%s_change' % (self.opts.app_label, + self.opts.model_name), + args=(quote(pk),), + current_app=self.model_admin.admin_site.name) + + +class TokenAdmin(admin.ModelAdmin): + list_display = ('key', 'user', 'device_type', 'created') + fields = ('user', 'device_id', 'device_type') + ordering = ('-created',) + actions = None # Actions not compatible with mapped IDs. + + def get_changelist(self, request, **kwargs): + return TokenChangeList + + def get_object(self, request, object_id, from_field=None): + """ + Map from User ID to matching Token. + """ + queryset = self.get_queryset(request) + field = User._meta.pk + try: + object_id = field.to_python(object_id) + user = User.objects.filter(**{field.name: object_id}).first() + return queryset.filter(user=user).first() + except (queryset.model.DoesNotExist, User.DoesNotExist, ValidationError, ValueError): + return None + + def delete_model(self, request, obj): + # Map back to actual Token, since delete() uses pk. + token = Token.objects.get(key=obj.key) + return super().delete_model(request, token) + + +admin.site.register(TokenProxy, TokenAdmin) diff --git a/drfpasswordless/authtoken/apps.py b/drfpasswordless/authtoken/apps.py new file mode 100644 index 0000000..d521c6a --- /dev/null +++ b/drfpasswordless/authtoken/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AuthTokenConfig(AppConfig): + name = 'drfpasswordless.authtoken' + verbose_name = _("Auth Token") diff --git a/drfpasswordless/authtoken/management/__init__.py b/drfpasswordless/authtoken/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/drfpasswordless/authtoken/management/commands/__init__.py b/drfpasswordless/authtoken/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/drfpasswordless/authtoken/management/commands/drf_create_token.py b/drfpasswordless/authtoken/management/commands/drf_create_token.py new file mode 100644 index 0000000..3d65392 --- /dev/null +++ b/drfpasswordless/authtoken/management/commands/drf_create_token.py @@ -0,0 +1,45 @@ +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError + +from rest_framework.authtoken.models import Token + +UserModel = get_user_model() + + +class Command(BaseCommand): + help = 'Create DRF Token for a given user' + + def create_user_token(self, username, reset_token): + user = UserModel._default_manager.get_by_natural_key(username) + + if reset_token: + Token.objects.filter(user=user).delete() + + token = Token.objects.get_or_create(user=user) + return token[0] + + def add_arguments(self, parser): + parser.add_argument('username', type=str) + + parser.add_argument( + '-r', + '--reset', + action='store_true', + dest='reset_token', + default=False, + help='Reset existing User token and create a new one', + ) + + def handle(self, *args, **options): + username = options['username'] + reset_token = options['reset_token'] + + try: + token = self.create_user_token(username, reset_token) + except UserModel.DoesNotExist: + raise CommandError( + 'Cannot create the Token: user {} does not exist'.format( + username) + ) + self.stdout.write( + 'Generated token {} for user {}'.format(token.key, username)) diff --git a/drfpasswordless/authtoken/migrations/0001_initial.py b/drfpasswordless/authtoken/migrations/0001_initial.py new file mode 100644 index 0000000..6a46ccf --- /dev/null +++ b/drfpasswordless/authtoken/migrations/0001_initial.py @@ -0,0 +1,23 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Token', + fields=[ + ('key', models.CharField(primary_key=True, serialize=False, max_length=40)), + ('created', models.DateTimeField(auto_now_add=True)), + ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL, related_name='auth_token', on_delete=models.CASCADE)), + ], + options={ + }, + bases=(models.Model,), + ), + ] diff --git a/drfpasswordless/authtoken/migrations/0002_auto_20160226_1747.py b/drfpasswordless/authtoken/migrations/0002_auto_20160226_1747.py new file mode 100644 index 0000000..4311909 --- /dev/null +++ b/drfpasswordless/authtoken/migrations/0002_auto_20160226_1747.py @@ -0,0 +1,31 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authtoken', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='token', + options={'verbose_name_plural': 'Tokens', 'verbose_name': 'Token'}, + ), + migrations.AlterField( + model_name='token', + name='created', + field=models.DateTimeField(verbose_name='Created', auto_now_add=True), + ), + migrations.AlterField( + model_name='token', + name='key', + field=models.CharField(verbose_name='Key', max_length=40, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='token', + name='user', + field=models.OneToOneField(to=settings.AUTH_USER_MODEL, verbose_name='User', related_name='auth_token', on_delete=models.CASCADE), + ), + ] diff --git a/drfpasswordless/authtoken/migrations/0003_tokenproxy.py b/drfpasswordless/authtoken/migrations/0003_tokenproxy.py new file mode 100644 index 0000000..79405a7 --- /dev/null +++ b/drfpasswordless/authtoken/migrations/0003_tokenproxy.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.1 on 2020-09-28 09:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('authtoken', '0002_auto_20160226_1747'), + ] + + operations = [ + migrations.CreateModel( + name='TokenProxy', + fields=[ + ], + options={ + 'verbose_name': 'token', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('authtoken.token',), + ), + ] diff --git a/drfpasswordless/authtoken/migrations/0004_auto_20240927_1224.py b/drfpasswordless/authtoken/migrations/0004_auto_20240927_1224.py new file mode 100644 index 0000000..43f43e3 --- /dev/null +++ b/drfpasswordless/authtoken/migrations/0004_auto_20240927_1224.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.18 on 2024-09-27 06:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('authtoken', '0003_tokenproxy'), + ] + + operations = [ + migrations.AddField( + model_name='token', + name='device_id', + field=models.CharField(blank=True, max_length=64, null=True), + ), + migrations.AddField( + model_name='token', + name='device_type', + field=models.CharField(blank=True, choices=[('WEB', 'WEB'), ('SPECTRO_TV', 'SPECTRO_TV'), ('LOG_SHEET', 'LOG_SHEET'), ('MELTING_REPORT', 'MELTING_REPORT'), ('IOT', 'IOT')], max_length=32, null=True), + ), + migrations.AlterField( + model_name='token', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_token', to=settings.AUTH_USER_MODEL, verbose_name='User'), + ), + ] diff --git a/drfpasswordless/authtoken/migrations/__init__.py b/drfpasswordless/authtoken/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/drfpasswordless/authtoken/models.py b/drfpasswordless/authtoken/models.py new file mode 100644 index 0000000..fe8d58e --- /dev/null +++ b/drfpasswordless/authtoken/models.py @@ -0,0 +1,70 @@ +import binascii +import os + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class Token(models.Model): + """ + The default authorization token model. + """ + WEB = "WEB" + SPECTRO_TV = "SPECTRO_TV" + LOG_SHEET = "LOG_SHEET" + MELTING_REPORT = "MELTING_REPORT" + IOT = "IOT" + DEVICE_TYPES = ( + (WEB, "WEB"), + (SPECTRO_TV, "SPECTRO_TV"), + (LOG_SHEET, "LOG_SHEET"), + (MELTING_REPORT, "MELTING_REPORT"), + (IOT, "IOT"), + ) + key = models.CharField(_("Key"), max_length=40, primary_key=True) + device_id = models.CharField(max_length=64, blank=True, null=True) + device_type = models.CharField( + choices=DEVICE_TYPES, max_length=32, blank=True, null=True + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, related_name='auth_token', + on_delete=models.CASCADE, verbose_name=_("User") + ) + created = models.DateTimeField(_("Created"), auto_now_add=True) + + class Meta: + # Work around for a bug in Django: + # https://code.djangoproject.com/ticket/19422 + # + # Also see corresponding ticket: + # https://github.com/encode/django-rest-framework/issues/705 + abstract = 'drfpasswordless.authtoken' not in settings.INSTALLED_APPS + verbose_name = _("Token") + verbose_name_plural = _("Tokens") + + def save(self, *args, **kwargs): + if not self.key: + self.key = self.generate_key() + return super().save(*args, **kwargs) + + @classmethod + def generate_key(cls): + return binascii.hexlify(os.urandom(20)).decode() + + def __str__(self): + return self.key + + +class TokenProxy(Token): + """ + Proxy mapping pk to user pk for use in admin. + """ + @property + def pk(self): + return self.user_id + + class Meta: + proxy = 'drfpasswordless.authtoken' in settings.INSTALLED_APPS + abstract = 'drfpasswordless.authtoken' not in settings.INSTALLED_APPS + verbose_name = "token" diff --git a/drfpasswordless/authtoken/serializers.py b/drfpasswordless/authtoken/serializers.py new file mode 100644 index 0000000..63e64d6 --- /dev/null +++ b/drfpasswordless/authtoken/serializers.py @@ -0,0 +1,42 @@ +from django.contrib.auth import authenticate +from django.utils.translation import gettext_lazy as _ + +from rest_framework import serializers + + +class AuthTokenSerializer(serializers.Serializer): + username = serializers.CharField( + label=_("Username"), + write_only=True + ) + password = serializers.CharField( + label=_("Password"), + style={'input_type': 'password'}, + trim_whitespace=False, + write_only=True + ) + token = serializers.CharField( + label=_("Token"), + read_only=True + ) + + def validate(self, attrs): + username = attrs.get('username') + password = attrs.get('password') + + if username and password: + user = authenticate(request=self.context.get('request'), + username=username, password=password) + + # The authenticate call simply returns None for is_active=False + # users. (Assuming the default ModelBackend authentication + # backend.) + if not user: + msg = _('Unable to log in with provided credentials.') + raise serializers.ValidationError(msg, code='authorization') + else: + msg = _('Must include "username" and "password".') + raise serializers.ValidationError(msg, code='authorization') + + attrs['user'] = user + return attrs diff --git a/drfpasswordless/authtoken/views.py b/drfpasswordless/authtoken/views.py new file mode 100644 index 0000000..50f9acb --- /dev/null +++ b/drfpasswordless/authtoken/views.py @@ -0,0 +1,62 @@ +from rest_framework import parsers, renderers +from rest_framework.authtoken.models import Token +from rest_framework.authtoken.serializers import AuthTokenSerializer +from rest_framework.compat import coreapi, coreschema +from rest_framework.response import Response +from rest_framework.schemas import ManualSchema +from rest_framework.schemas import coreapi as coreapi_schema +from rest_framework.views import APIView + + +class ObtainAuthToken(APIView): + throttle_classes = () + permission_classes = () + parser_classes = (parsers.FormParser, parsers.MultiPartParser, parsers.JSONParser,) + renderer_classes = (renderers.JSONRenderer,) + serializer_class = AuthTokenSerializer + + if coreapi_schema.is_enabled(): + schema = ManualSchema( + fields=[ + coreapi.Field( + name="username", + required=True, + location='form', + schema=coreschema.String( + title="Username", + description="Valid username for authentication", + ), + ), + coreapi.Field( + name="password", + required=True, + location='form', + schema=coreschema.String( + title="Password", + description="Valid password for authentication", + ), + ), + ], + encoding="application/json", + ) + + def get_serializer_context(self): + return { + 'request': self.request, + 'format': self.format_kwarg, + 'view': self + } + + def get_serializer(self, *args, **kwargs): + kwargs['context'] = self.get_serializer_context() + return self.serializer_class(*args, **kwargs) + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data['user'] + token, created = Token.objects.get_or_create(user=user) + return Response({'token': token.key}) + + +obtain_auth_token = ObtainAuthToken.as_view() From 0e2a9f507a93f6362399aafca2b070ef26558192 Mon Sep 17 00:00:00 2001 From: anish Date: Mon, 30 Sep 2024 10:56:31 +0530 Subject: [PATCH 04/18] Updated version --- drfpasswordless/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drfpasswordless/__version__.py b/drfpasswordless/__version__.py index 8efe3d1..147d0ba 100644 --- a/drfpasswordless/__version__.py +++ b/drfpasswordless/__version__.py @@ -1,3 +1,3 @@ -VERSION = (1, 5, 8) +VERSION = (1, 5, 9) __version__ = '.'.join(map(str, VERSION)) From 5c4d58348a882f9b26f6e9946624ac95d086d660 Mon Sep 17 00:00:00 2001 From: anish Date: Fri, 4 Oct 2024 11:52:17 +0530 Subject: [PATCH 05/18] model changes and migration --- .../authtoken/migrations/0004_auto_20240927_1224.py | 6 +++++- drfpasswordless/authtoken/models.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/drfpasswordless/authtoken/migrations/0004_auto_20240927_1224.py b/drfpasswordless/authtoken/migrations/0004_auto_20240927_1224.py index 43f43e3..f28f5e6 100644 --- a/drfpasswordless/authtoken/migrations/0004_auto_20240927_1224.py +++ b/drfpasswordless/authtoken/migrations/0004_auto_20240927_1224.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.18 on 2024-09-27 06:54 +# Generated by Django 3.2.18 on 2024-10-04 06:21 from django.conf import settings from django.db import migrations, models @@ -28,4 +28,8 @@ class Migration(migrations.Migration): name='user', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_token', to=settings.AUTH_USER_MODEL, verbose_name='User'), ), + migrations.AlterUniqueTogether( + name='token', + unique_together={('key', 'device_id')}, + ), ] diff --git a/drfpasswordless/authtoken/models.py b/drfpasswordless/authtoken/models.py index fe8d58e..0bb7d95 100644 --- a/drfpasswordless/authtoken/models.py +++ b/drfpasswordless/authtoken/models.py @@ -42,6 +42,7 @@ class Meta: abstract = 'drfpasswordless.authtoken' not in settings.INSTALLED_APPS verbose_name = _("Token") verbose_name_plural = _("Tokens") + unique_together = (('key', 'device_id'),) def save(self, *args, **kwargs): if not self.key: From fb5f77ee15ad33272411198488bfa8328cc6c40d Mon Sep 17 00:00:00 2001 From: anish Date: Fri, 4 Oct 2024 12:01:12 +0530 Subject: [PATCH 06/18] Token create change --- .../management/commands/drf_create_token.py | 2 +- drfpasswordless/authtoken/views.py | 2 +- drfpasswordless/serializers.py | 2 ++ drfpasswordless/utils.py | 9 +++++---- drfpasswordless/views.py | 20 +++++++++++++++---- tests/test_settings.py | 2 +- tests/test_verification.py | 2 +- 7 files changed, 27 insertions(+), 12 deletions(-) diff --git a/drfpasswordless/authtoken/management/commands/drf_create_token.py b/drfpasswordless/authtoken/management/commands/drf_create_token.py index 3d65392..4705c8a 100644 --- a/drfpasswordless/authtoken/management/commands/drf_create_token.py +++ b/drfpasswordless/authtoken/management/commands/drf_create_token.py @@ -1,7 +1,7 @@ from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand, CommandError -from rest_framework.authtoken.models import Token +from drfpasswordless.authtoken.models import Token UserModel = get_user_model() diff --git a/drfpasswordless/authtoken/views.py b/drfpasswordless/authtoken/views.py index 50f9acb..0baf739 100644 --- a/drfpasswordless/authtoken/views.py +++ b/drfpasswordless/authtoken/views.py @@ -1,5 +1,5 @@ from rest_framework import parsers, renderers -from rest_framework.authtoken.models import Token +from drfpasswordless.authtoken.models import Token from rest_framework.authtoken.serializers import AuthTokenSerializer from rest_framework.compat import coreapi, coreschema from rest_framework.response import Response diff --git a/drfpasswordless/serializers.py b/drfpasswordless/serializers.py index 478ec8b..81ac3e3 100644 --- a/drfpasswordless/serializers.py +++ b/drfpasswordless/serializers.py @@ -200,6 +200,8 @@ def validate_alias(self, attrs): class CallbackTokenAuthSerializer(AbstractBaseCallbackTokenSerializer): + device_id = serializers.CharField(required=False, max_length=64) + device_type = serializers.ChoiceField(choices=Token.DEVICE_TYPES, required=False) def validate(self, attrs): # Check Aliases diff --git a/drfpasswordless/utils.py b/drfpasswordless/utils.py index ba02deb..21ec1f7 100644 --- a/drfpasswordless/utils.py +++ b/drfpasswordless/utils.py @@ -5,7 +5,7 @@ from django.core.mail import send_mail from django.template import loader from django.utils import timezone -from rest_framework.authtoken.models import Token +from drfpasswordless.authtoken.models import Token from drfpasswordless.models import CallbackToken from drfpasswordless.settings import api_settings @@ -208,6 +208,7 @@ def send_sms_with_callback_token(user, mobile_token, **kwargs): return False -def create_authentication_token(user): - """ Default way to create an authentication token""" - return Token.objects.get_or_create(user=user) +def create_authentication_token(user, device_id="", device_type=""): + return Token.objects.get_or_create( + user=user, device_id=device_id, device_type=device_type + ) diff --git a/drfpasswordless/views.py b/drfpasswordless/views.py index 6692ac8..e4b2a1e 100644 --- a/drfpasswordless/views.py +++ b/drfpasswordless/views.py @@ -134,14 +134,19 @@ class AbstractBaseObtainAuthToken(APIView): This is a duplicate of rest_framework's own ObtainAuthToken method. Instead, this returns an Auth Token based on our 6 digit callback token and source. """ + serializer_class = None def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) if serializer.is_valid(raise_exception=True): - user = serializer.validated_data['user'] + user = serializer.validated_data["user"] token_creator = import_string(api_settings.PASSWORDLESS_AUTH_TOKEN_CREATOR) - (token, _) = token_creator(user) + (token, _) = token_creator( + user=user, + device_id=serializer.validated_data.get("device_id", ""), + device_type=serializer.validated_data.get("device_type", ""), + ) if token: TokenSerializer = import_string(api_settings.PASSWORDLESS_AUTH_TOKEN_SERIALIZER) @@ -150,8 +155,15 @@ def post(self, request, *args, **kwargs): # Return our key for consumption. return Response(token_serializer.data, status=status.HTTP_200_OK) else: - logger.error("Couldn't log in unknown user. Errors on serializer: {}".format(serializer.error_messages)) - return Response({'detail': 'Couldn\'t log you in. Try again later.'}, status=status.HTTP_400_BAD_REQUEST) + logger.error( + "Couldn't log in unknown user. Errors on serializer: {}".format( + serializer.error_messages + ) + ) + return Response( + {"detail": "Couldn't log you in. Try again later."}, + status=status.HTTP_400_BAD_REQUEST, + ) class ObtainAuthTokenFromCallbackToken(AbstractBaseObtainAuthToken): diff --git a/tests/test_settings.py b/tests/test_settings.py index 7fe709d..cbc2ba6 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,5 +1,5 @@ from rest_framework import status -from rest_framework.authtoken.models import Token +from drfpasswordless.authtoken.models import Token from rest_framework.test import APITestCase from django.contrib.auth import get_user_model diff --git a/tests/test_verification.py b/tests/test_verification.py index 16a0464..c519663 100644 --- a/tests/test_verification.py +++ b/tests/test_verification.py @@ -1,5 +1,5 @@ from rest_framework import status -from rest_framework.authtoken.models import Token +from drfpasswordless.authtoken.models import Token from django.utils.translation import gettext_lazy as _ from rest_framework.test import APITestCase from django.contrib.auth import get_user_model From 415ceb7fddcb8c1a31e9d1093e1ae856b91c8d3b Mon Sep 17 00:00:00 2001 From: anish Date: Fri, 4 Oct 2024 12:35:25 +0530 Subject: [PATCH 07/18] fix --- drfpasswordless/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drfpasswordless/serializers.py b/drfpasswordless/serializers.py index 81ac3e3..2c15fc3 100644 --- a/drfpasswordless/serializers.py +++ b/drfpasswordless/serializers.py @@ -8,7 +8,7 @@ from drfpasswordless.models import CallbackToken from drfpasswordless.settings import api_settings from drfpasswordless.utils import verify_user_alias, validate_token_age - +from drfpasswordless.authtoken.models import Token logger = logging.getLogger(__name__) User = get_user_model() From 9f084f982a39f2347cec00195c587aae4f1b3285 Mon Sep 17 00:00:00 2001 From: anish Date: Fri, 4 Oct 2024 14:05:23 +0530 Subject: [PATCH 08/18] Made device_id unique --- .../authtoken/migrations/0004_auto_20240927_1224.py | 8 ++++---- drfpasswordless/authtoken/models.py | 7 +++++++ drfpasswordless/utils.py | 4 ++-- drfpasswordless/views.py | 4 ++-- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/drfpasswordless/authtoken/migrations/0004_auto_20240927_1224.py b/drfpasswordless/authtoken/migrations/0004_auto_20240927_1224.py index f28f5e6..abd63cc 100644 --- a/drfpasswordless/authtoken/migrations/0004_auto_20240927_1224.py +++ b/drfpasswordless/authtoken/migrations/0004_auto_20240927_1224.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.18 on 2024-10-04 06:21 +# Generated by Django 3.2.18 on 2024-10-04 08:26 from django.conf import settings from django.db import migrations, models @@ -28,8 +28,8 @@ class Migration(migrations.Migration): name='user', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_token', to=settings.AUTH_USER_MODEL, verbose_name='User'), ), - migrations.AlterUniqueTogether( - name='token', - unique_together={('key', 'device_id')}, + migrations.AddConstraint( + model_name='token', + constraint=models.UniqueConstraint(condition=models.Q(('device_id__isnull', False), models.Q(('device_id', ''), _negated=True)), fields=('device_id',), name='unique_device_id_not_null_blank'), ), ] diff --git a/drfpasswordless/authtoken/models.py b/drfpasswordless/authtoken/models.py index 0bb7d95..46acdd7 100644 --- a/drfpasswordless/authtoken/models.py +++ b/drfpasswordless/authtoken/models.py @@ -43,6 +43,13 @@ class Meta: verbose_name = _("Token") verbose_name_plural = _("Tokens") unique_together = (('key', 'device_id'),) + constraints = [ + models.UniqueConstraint( + fields=['device_id'], + name='unique_device_id_not_null_blank', + condition=Q(device_id__isnull=False) & ~Q(device_id='') + ) + ] def save(self, *args, **kwargs): if not self.key: diff --git a/drfpasswordless/utils.py b/drfpasswordless/utils.py index 21ec1f7..6a92b8d 100644 --- a/drfpasswordless/utils.py +++ b/drfpasswordless/utils.py @@ -210,5 +210,5 @@ def send_sms_with_callback_token(user, mobile_token, **kwargs): def create_authentication_token(user, device_id="", device_type=""): return Token.objects.get_or_create( - user=user, device_id=device_id, device_type=device_type - ) + user=user, device_id=device_id, defaults={"device_type": device_type} + ) \ No newline at end of file diff --git a/drfpasswordless/views.py b/drfpasswordless/views.py index e4b2a1e..9e6ffbd 100644 --- a/drfpasswordless/views.py +++ b/drfpasswordless/views.py @@ -144,8 +144,8 @@ def post(self, request, *args, **kwargs): token_creator = import_string(api_settings.PASSWORDLESS_AUTH_TOKEN_CREATOR) (token, _) = token_creator( user=user, - device_id=serializer.validated_data.get("device_id", ""), - device_type=serializer.validated_data.get("device_type", ""), + device_id=serializer.validated_data.get("device_id", None), + device_type=serializer.validated_data.get("device_type", None), ) if token: From a9755324c3b5c3ebe0db08ebb7f19f19706e8cf2 Mon Sep 17 00:00:00 2001 From: anish Date: Sat, 5 Oct 2024 11:56:45 +0530 Subject: [PATCH 09/18] Import Q from models --- drfpasswordless/authtoken/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drfpasswordless/authtoken/models.py b/drfpasswordless/authtoken/models.py index 46acdd7..33f3397 100644 --- a/drfpasswordless/authtoken/models.py +++ b/drfpasswordless/authtoken/models.py @@ -1,6 +1,6 @@ import binascii import os - +from django.db.models import Q from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ From c5d753d9b7f567e37b765bfcbbd85c51c266fb0c Mon Sep 17 00:00:00 2001 From: anish Date: Thu, 17 Oct 2024 11:17:57 +0530 Subject: [PATCH 10/18] Remove Unique Constrain --- ...ve_token_unique_device_id_not_null_blank.py | 18 ++++++++++++++++++ drfpasswordless/authtoken/models.py | 8 -------- 2 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 drfpasswordless/authtoken/migrations/0005_remove_token_unique_device_id_not_null_blank.py diff --git a/drfpasswordless/authtoken/migrations/0005_remove_token_unique_device_id_not_null_blank.py b/drfpasswordless/authtoken/migrations/0005_remove_token_unique_device_id_not_null_blank.py new file mode 100644 index 0000000..081a30c --- /dev/null +++ b/drfpasswordless/authtoken/migrations/0005_remove_token_unique_device_id_not_null_blank.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2024-10-17 05:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('authtoken', '0004_auto_20240927_1224'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='token', + name='unique_device_id_not_null_blank', + ), + ] + diff --git a/drfpasswordless/authtoken/models.py b/drfpasswordless/authtoken/models.py index 33f3397..7133030 100644 --- a/drfpasswordless/authtoken/models.py +++ b/drfpasswordless/authtoken/models.py @@ -42,14 +42,6 @@ class Meta: abstract = 'drfpasswordless.authtoken' not in settings.INSTALLED_APPS verbose_name = _("Token") verbose_name_plural = _("Tokens") - unique_together = (('key', 'device_id'),) - constraints = [ - models.UniqueConstraint( - fields=['device_id'], - name='unique_device_id_not_null_blank', - condition=Q(device_id__isnull=False) & ~Q(device_id='') - ) - ] def save(self, *args, **kwargs): if not self.key: From 98cb82b380c44ad953dd8c844a3e68e8c4bafeea Mon Sep 17 00:00:00 2001 From: anish Date: Thu, 17 Oct 2024 12:50:13 +0530 Subject: [PATCH 11/18] Added unique constrain user and device_id --- ...ce_id_not_null_blank.py => 0005_auto_20241017_1246.py} | 8 +++++++- drfpasswordless/authtoken/models.py | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) rename drfpasswordless/authtoken/migrations/{0005_remove_token_unique_device_id_not_null_blank.py => 0005_auto_20241017_1246.py} (52%) diff --git a/drfpasswordless/authtoken/migrations/0005_remove_token_unique_device_id_not_null_blank.py b/drfpasswordless/authtoken/migrations/0005_auto_20241017_1246.py similarity index 52% rename from drfpasswordless/authtoken/migrations/0005_remove_token_unique_device_id_not_null_blank.py rename to drfpasswordless/authtoken/migrations/0005_auto_20241017_1246.py index 081a30c..8d36a5a 100644 --- a/drfpasswordless/authtoken/migrations/0005_remove_token_unique_device_id_not_null_blank.py +++ b/drfpasswordless/authtoken/migrations/0005_auto_20241017_1246.py @@ -1,11 +1,13 @@ -# Generated by Django 3.2.18 on 2024-10-17 05:42 +# Generated by Django 3.2.18 on 2024-10-17 07:16 +from django.conf import settings from django.db import migrations class Migration(migrations.Migration): dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('authtoken', '0004_auto_20240927_1224'), ] @@ -14,5 +16,9 @@ class Migration(migrations.Migration): model_name='token', name='unique_device_id_not_null_blank', ), + migrations.AlterUniqueTogether( + name='token', + unique_together={('user', 'device_id')}, + ), ] diff --git a/drfpasswordless/authtoken/models.py b/drfpasswordless/authtoken/models.py index 7133030..1df2aa9 100644 --- a/drfpasswordless/authtoken/models.py +++ b/drfpasswordless/authtoken/models.py @@ -42,6 +42,7 @@ class Meta: abstract = 'drfpasswordless.authtoken' not in settings.INSTALLED_APPS verbose_name = _("Token") verbose_name_plural = _("Tokens") + unique_together = (('user', 'device_id'),) def save(self, *args, **kwargs): if not self.key: From a666829113785ab2a45757f7f9a9351df8bd1d46 Mon Sep 17 00:00:00 2001 From: anish Date: Sun, 20 Oct 2024 10:37:24 +0530 Subject: [PATCH 12/18] Update version to 1.6.0 This change updates the version from 1.5.9 to 1.6.0 in the __version__.py file. --- drfpasswordless/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drfpasswordless/__version__.py b/drfpasswordless/__version__.py index 147d0ba..e82594e 100644 --- a/drfpasswordless/__version__.py +++ b/drfpasswordless/__version__.py @@ -1,3 +1,3 @@ -VERSION = (1, 5, 9) +VERSION = (1, 6, 0) __version__ = '.'.join(map(str, VERSION)) From d6e08d1c47445096b5c199a7767500ef2d47804b Mon Sep 17 00:00:00 2001 From: wick-prat Date: Tue, 17 Dec 2024 09:41:36 +0530 Subject: [PATCH 13/18] added user, device_id and device_type to readonly fields. added filter for device_type in Token listing page. --- drfpasswordless/authtoken/admin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/drfpasswordless/authtoken/admin.py b/drfpasswordless/authtoken/admin.py index f24a07f..0f055a6 100644 --- a/drfpasswordless/authtoken/admin.py +++ b/drfpasswordless/authtoken/admin.py @@ -25,6 +25,8 @@ class TokenAdmin(admin.ModelAdmin): fields = ('user', 'device_id', 'device_type') ordering = ('-created',) actions = None # Actions not compatible with mapped IDs. + readonly_fields = ("user", "device_id", "device_type") + list_filter = ("device_type",) def get_changelist(self, request, **kwargs): return TokenChangeList From ea087bbf5ce1cde6396030fd929173705d2ce19c Mon Sep 17 00:00:00 2001 From: wick-prat Date: Tue, 17 Dec 2024 11:27:50 +0530 Subject: [PATCH 14/18] changed version from 1.6.0 to 1.6.1 --- drfpasswordless/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drfpasswordless/__version__.py b/drfpasswordless/__version__.py index e82594e..8474ee1 100644 --- a/drfpasswordless/__version__.py +++ b/drfpasswordless/__version__.py @@ -1,3 +1,3 @@ -VERSION = (1, 6, 0) +VERSION = (1, 6, 1) __version__ = '.'.join(map(str, VERSION)) From 3487c62f8a34c66c400c6a65e85c2fb7fea504bd Mon Sep 17 00:00:00 2001 From: anish Date: Thu, 23 Jan 2025 20:28:41 +0530 Subject: [PATCH 15/18] Allow deive type to be different --- drfpasswordless/serializers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/drfpasswordless/serializers.py b/drfpasswordless/serializers.py index 2c15fc3..98edd33 100644 --- a/drfpasswordless/serializers.py +++ b/drfpasswordless/serializers.py @@ -247,6 +247,13 @@ def validate(self, attrs): msg = _('Invalid alias parameters provided.') raise serializers.ValidationError(msg) + def to_internal_value(self, data): + device_type = data.get('device_type', None) + if device_type and device_type not in dict(Token.DEVICE_TYPES).keys(): + del data['device_type'] + + return super().to_internal_value(data) + class CallbackTokenVerificationSerializer(AbstractBaseCallbackTokenSerializer): """ From 0e5de94acd262b86fb3b8831cd7890861b837437 Mon Sep 17 00:00:00 2001 From: anish Date: Thu, 23 Jan 2025 20:34:06 +0530 Subject: [PATCH 16/18] Allow deive type to be different --- drfpasswordless/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drfpasswordless/__version__.py b/drfpasswordless/__version__.py index e82594e..0b24c2c 100644 --- a/drfpasswordless/__version__.py +++ b/drfpasswordless/__version__.py @@ -1,3 +1,3 @@ -VERSION = (1, 6, 0) +VERSION = (1, 6, 3) __version__ = '.'.join(map(str, VERSION)) From 0e174bd6d64c07b961420c1f2676760cac28f001 Mon Sep 17 00:00:00 2001 From: anish Date: Thu, 20 Mar 2025 00:39:12 +0530 Subject: [PATCH 17/18] changes Max attempt changes Max attempt changes Max attempt changes --- .../migrations/0006_callbacktoken_attempts.py | 18 ++++++++++++++++++ drfpasswordless/models.py | 10 ++++++++++ drfpasswordless/serializers.py | 15 +++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 drfpasswordless/migrations/0006_callbacktoken_attempts.py diff --git a/drfpasswordless/migrations/0006_callbacktoken_attempts.py b/drfpasswordless/migrations/0006_callbacktoken_attempts.py new file mode 100644 index 0000000..53ec959 --- /dev/null +++ b/drfpasswordless/migrations/0006_callbacktoken_attempts.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2025-03-20 05:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('drfpasswordless', '0005_auto_20201117_0410'), + ] + + operations = [ + migrations.AddField( + model_name='callbacktoken', + name='attempts', + field=models.IntegerField(default=0), + ), + ] diff --git a/drfpasswordless/models.py b/drfpasswordless/models.py index 6d3b677..081473d 100644 --- a/drfpasswordless/models.py +++ b/drfpasswordless/models.py @@ -66,6 +66,16 @@ class CallbackToken(AbstractBaseCallbackToken): key = models.CharField(default=generate_numeric_token, max_length=6) type = models.CharField(max_length=20, choices=TOKEN_TYPES) + attempts = models.IntegerField(default=0) class Meta(AbstractBaseCallbackToken.Meta): verbose_name = 'Callback Token' + + def increment_attempts(self): + """ + Increment the number of attempts and deactivate if max attempts reached + """ + self.attempts += 1 + if self.attempts > 3: # Max 3 attempts + self.is_active = False + self.save() diff --git a/drfpasswordless/serializers.py b/drfpasswordless/serializers.py index 98edd33..f56ee08 100644 --- a/drfpasswordless/serializers.py +++ b/drfpasswordless/serializers.py @@ -178,7 +178,6 @@ class AbstractBaseCallbackTokenSerializer(serializers.Serializer): token = TokenField( min_length=api_settings.PASSWORDLESS_TOKEN_LENGTH, max_length=api_settings.PASSWORDLESS_TOKEN_LENGTH, - validators=[token_age_validator] ) def validate_alias(self, attrs): @@ -209,6 +208,18 @@ def validate(self, attrs): alias_type, alias = self.validate_alias(attrs) callback_token = attrs.get('token', None) user = User.objects.get(**{alias_type+'__iexact': alias}) + + + #increment the attempt + token = CallbackToken.objects.filter(**{'user': user, + 'type': CallbackToken.TOKEN_TYPE_AUTH, + 'is_active': True}).first() + + if token: + token.increment_attempts() + + validate_token_age(callback_token) + token = CallbackToken.objects.get(**{'user': user, 'key': callback_token, 'type': CallbackToken.TOKEN_TYPE_AUTH, @@ -238,7 +249,7 @@ def validate(self, attrs): msg = _('Invalid Token') raise serializers.ValidationError(msg) except CallbackToken.DoesNotExist: - msg = _('Invalid alias parameters provided.') + msg = _('Invalid OTP or OTP may be expired') raise serializers.ValidationError(msg) except User.DoesNotExist: msg = _('Invalid user alias parameters provided.') From 3fd778af0a378d065e6b3b8cb3acd218ae3933be Mon Sep 17 00:00:00 2001 From: anish Date: Fri, 21 Mar 2025 11:06:06 +0530 Subject: [PATCH 18/18] version upgrade --- drfpasswordless/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drfpasswordless/__version__.py b/drfpasswordless/__version__.py index 0b24c2c..0a630a2 100644 --- a/drfpasswordless/__version__.py +++ b/drfpasswordless/__version__.py @@ -1,3 +1,3 @@ -VERSION = (1, 6, 3) +VERSION = (1, 6, 4) __version__ = '.'.join(map(str, VERSION))