diff --git a/CHANGELOG.md b/CHANGELOG.md index 18498f5..a6022b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Added optional "name" field for MultiToken model +- Updated README test instructions ## [2.0.0] diff --git a/README.md b/README.md index 7127e42..8fe3cad 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ urlpatterns = [ The following endpoints are provided: - * `login` - takes username and password; on success an auth token is returned + * `login` - takes username, password and an optional token_name; on success an auth token is returned * `logout` ## Signals @@ -71,10 +71,20 @@ The following endpoints are provided: See folder [tests/](tests/). Basically, all endpoints are covered with multiple unit tests. -Use this code snippet to run tests: +Follow below instructions to run the tests. +You may exchange the installed Django and DRF versions according to your requirements. +:warning: Depending on your local environment settings you might need to explicitly call `python3` instead of `python`. ```bash -pip install tox -tox +# install dependencies +python -m pip install --upgrade pip +pip install -r requirements.txt + +# setup environment +pip install -e . +python setup.py install + +# run tests +cd tests && python manage.py test ``` ## Cache Backend diff --git a/drf_multitokenauth/admin.py b/drf_multitokenauth/admin.py index cf07738..d1e7ecc 100644 --- a/drf_multitokenauth/admin.py +++ b/drf_multitokenauth/admin.py @@ -5,4 +5,4 @@ @admin.register(MultiToken) class MultiTokenAdmin(admin.ModelAdmin): - list_display = ('user', 'key', 'user_agent') + list_display = ('user', 'name', 'key', 'user_agent') diff --git a/drf_multitokenauth/migrations/0004_multitoken_name.py b/drf_multitokenauth/migrations/0004_multitoken_name.py new file mode 100644 index 0000000..2520dd9 --- /dev/null +++ b/drf_multitokenauth/migrations/0004_multitoken_name.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2022-10-21 05:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('drf_multitokenauth', '0003_pk_migration'), + ] + + operations = [ + migrations.AddField( + model_name='multitoken', + name='name', + field=models.CharField(default='', max_length=256, verbose_name='Token name'), + ), + ] diff --git a/drf_multitokenauth/models.py b/drf_multitokenauth/models.py index 9c526b6..af87ed7 100644 --- a/drf_multitokenauth/models.py +++ b/drf_multitokenauth/models.py @@ -50,6 +50,11 @@ class MultiToken(models.Model): verbose_name=_("HTTP User Agent"), default="" ) + name = models.CharField( + max_length=256, + verbose_name=_("Token name"), + default="" + ) class Meta: # Work around for a bug in Django: @@ -72,6 +77,6 @@ def generate_key(): return binascii.hexlify(os.urandom(32)).decode() def __str__(self): - return "{} (user {} with IP {} and user-agent {})".format( - self.key, self.user, self.last_known_ip, self.user_agent + return "{} ({} for user {} with IP {} and user-agent {})".format( + self.key, self.name, self.user, self.last_known_ip, self.user_agent ) diff --git a/drf_multitokenauth/serializers.py b/drf_multitokenauth/serializers.py index 4fdc514..7925251 100644 --- a/drf_multitokenauth/serializers.py +++ b/drf_multitokenauth/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from rest_framework.authtoken.serializers import AuthTokenSerializer __all__ = [ 'EmailSerializer', @@ -7,3 +8,7 @@ class EmailSerializer(serializers.Serializer): email = serializers.EmailField() + + +class MultiAuthTokenSerializer(AuthTokenSerializer): + token_name = serializers.CharField(required=False, default="", allow_blank=True) diff --git a/drf_multitokenauth/urls.py b/drf_multitokenauth/urls.py index 2757dc5..1d6d6cc 100644 --- a/drf_multitokenauth/urls.py +++ b/drf_multitokenauth/urls.py @@ -1,9 +1,9 @@ """ URL Configuration for core auth """ -from django.conf.urls import include -from drf_multitokenauth.views import login_and_obtain_auth_token, logout_and_delete_auth_token from django.urls import re_path +from drf_multitokenauth.views import login_and_obtain_auth_token, logout_and_delete_auth_token + app_name = 'drf_multitokenauth' urlpatterns = [ diff --git a/drf_multitokenauth/views.py b/drf_multitokenauth/views.py index 73b163d..e805441 100644 --- a/drf_multitokenauth/views.py +++ b/drf_multitokenauth/views.py @@ -1,19 +1,13 @@ -from datetime import timedelta from django.conf import settings -from django.contrib.auth.models import User, update_last_login -from django.core.exceptions import ValidationError -from django.utils import timezone -from django.utils.translation import gettext_lazy as _ - +from django.contrib.auth.models import update_last_login from ipware import get_client_ip from rest_framework import parsers, renderers, status -from rest_framework.authtoken.serializers import AuthTokenSerializer +from rest_framework.authentication import get_authorization_header from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework.authentication import get_authorization_header from drf_multitokenauth.models import MultiToken -from drf_multitokenauth.serializers import EmailSerializer +from drf_multitokenauth.serializers import MultiAuthTokenSerializer from drf_multitokenauth.signals import pre_auth, post_auth __all__ = [ @@ -50,20 +44,23 @@ class LoginAndObtainAuthToken(APIView): permission_classes = () parser_classes = (parsers.FormParser, parsers.MultiPartParser, parsers.JSONParser,) renderer_classes = (renderers.JSONRenderer,) - serializer_class = AuthTokenSerializer + serializer_class = MultiAuthTokenSerializer def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) + + user = serializer.validated_data['user'] + token_name = serializer.validated_data['token_name'] + # fire pre_auth signal pre_auth.send( sender=self.__class__, username=request.data['username'], - password=request.data['password'] + password=request.data['password'], + token_name=token_name ) - user = serializer.validated_data['user'] - superuser_login_enabled = getattr(settings, 'AUTH_ENABLE_SUPERUSER_LOGIN', True) if not superuser_login_enabled and user.is_superuser: return Response({'error': 'superusers can\'t log in'}, status=status.HTTP_403_FORBIDDEN) @@ -75,6 +72,7 @@ def post(self, request, *args, **kwargs): user=user, user_agent=request.META.get('HTTP_USER_AGENT', ''), last_known_ip=get_client_ip(request)[0], + name=token_name, ) # fire post_auth signal diff --git a/tests/test.py b/tests/test.py index b4b2de9..e0a6152 100644 --- a/tests/test.py +++ b/tests/test.py @@ -1,12 +1,14 @@ import json +from unittest.mock import patch + from django.contrib.auth.models import User from django.db.models import Q from django.test import override_settings +from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase + from drf_multitokenauth.models import MultiToken -from unittest.mock import patch -from django.urls import reverse class HelperMixin: @@ -26,12 +28,16 @@ def reset_client_credentials(self): """ reset all client credentials """ self.client.credentials() - def rest_do_login(self, username, password, HTTP_USER_AGENT='', REMOTE_ADDR='127.0.0.1'): + def rest_do_login(self, username, password, token_name=None, HTTP_USER_AGENT='', REMOTE_ADDR='127.0.0.1'): """ REST API Wrapper for login """ data = { 'username': username, 'password': password } + + if token_name is not None: + data['token_name'] = token_name + return self.client.post( self.login_url, data, @@ -64,8 +70,8 @@ def setUp(self): self.user2 = User.objects.create_user("user2", "user2@mail.com", "secret2") self.superuser = User.objects.create_superuser("superuser", "superuser@mail.com", "secret3") - def login_and_obtain_token(self, username, password, HTTP_USER_AGENT='', REMOTE_ADDR='127.0.0.1'): - response = self.rest_do_login(username, password, HTTP_USER_AGENT, REMOTE_ADDR) + def login_and_obtain_token(self, username, password, token_name=None, HTTP_USER_AGENT='', REMOTE_ADDR='127.0.0.1'): + response = self.rest_do_login(username, password, token_name, HTTP_USER_AGENT, REMOTE_ADDR) self.assertContains(response, "{\"token\":\"") content = json.loads(response.content.decode()) @@ -143,6 +149,87 @@ def test_login_multiple_times(self): 2 ) + def test_login_with_token_name(self): + """ tests login several times, using the same or different token_names for one or more users """ + # there should be zero tokens + self.assertEqual(MultiToken.objects.all().count(), 0) + + # login first time without token_name (defaults to '') + token1 = self.login_and_obtain_token('user1', 'secret1') + self.assertEqual(MultiToken.objects.all().count(), 1) + # verify the token is for user 1 and has an empty token_name + multi_token1 = MultiToken.objects.filter(key=token1).first() + self.assertEqual( + multi_token1.user.username, + 'user1' + ) + self.assertEqual(multi_token1.name, '') + + # login second time with empty token_name '' + token2 = self.login_and_obtain_token('user1', 'secret1', '') + self.assertEqual(MultiToken.objects.all().count(), 2) + # verify the token is for user 1 and has an empty token_name + multi_token2 = MultiToken.objects.filter(key=token2).first() + self.assertEqual( + multi_token2.user.username, + 'user1' + ) + self.assertEqual(multi_token2.name, '') + # verify that token1 is not equal to token2 + self.assertNotEqual(token1, token2) + + # login third time, with token_name + token3 = self.login_and_obtain_token('user1', 'secret1', 'test_token_name1') + self.assertEqual(MultiToken.objects.all().count(), 3) + # verify the token is for user 1 and has an empty token_name + multi_token3 = MultiToken.objects.filter(key=token3).first() + self.assertEqual( + multi_token3.user.username, + 'user1' + ) + self.assertEqual(multi_token3.name, 'test_token_name1') + # verify that token2 is not equal to token3 + self.assertNotEqual(token2, token3) + + # login third time, with same token_name + token4 = self.login_and_obtain_token('user1', 'secret1', 'test_token_name1') + self.assertEqual(MultiToken.objects.all().count(), 4) + # verify the token is for user 1 and has an empty token_name + multi_token4 = MultiToken.objects.filter(key=token4).first() + self.assertEqual( + multi_token4.user.username, + 'user1' + ) + self.assertEqual(multi_token4.name, 'test_token_name1') + # verify that token3 is not equal to token4 + self.assertNotEqual(token3, token4) + + # login with another user and the same token_name + token5 = self.login_and_obtain_token('user2', 'secret2', 'test_token_name1') + self.assertEqual(MultiToken.objects.all().count(), 5) + # verify the token is for user 1 and has an empty token_name + multi_token5 = MultiToken.objects.filter(key=token5).first() + self.assertEqual( + multi_token5.user.username, + 'user2' + ) + self.assertEqual(multi_token5.name, 'test_token_name1') + # verify that token4 is not equal to token5 + self.assertNotEqual(token4, token5) + + # login with same user and different token_name + token6 = self.login_and_obtain_token('user2', 'secret2', 'test_token_name2') + self.assertEqual(MultiToken.objects.all().count(), 6) + # verify the token is for user 1 and has an empty token_name + multi_token6 = MultiToken.objects.filter(key=token6).first() + self.assertEqual( + multi_token6.user.username, + 'user2' + ) + self.assertEqual(multi_token6.name, 'test_token_name2') + # verify that token5 is not equal to token6 + self.assertNotEqual(token5, token6) + def test_login_with_invalid_credentials(self): """ tests login with invalid credentials """ # log in one time, just to be sure that the login works