diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2c1344e1..f061f9e8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,13 @@ +###### +2.2.1 +###### +**Please be aware: updating to his version requires applying a database migration** + +- Introducing token_key to avoid loop over all tokens on login-requests +- Signals are sent on login/logout +- Test for invalid token length +- Cleanup in code and documentation + ###### 2.2.0 ###### diff --git a/docs/settings.md b/docs/settings.md index 84dff1cf..944e19cf 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -8,10 +8,11 @@ Example `settings.py` ```python #...snip... # These are the default values if none are set +from datetime import timedelta 'REST_KNOX' = { 'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA512', 'AUTH_TOKEN_CHARACTER_LENGTH': 64, - 'TOKEN_TTL': 10, + 'TOKEN_TTL': timedelta(hours=10), 'USER_SERIALIZER': 'knox.serializers.UserSerializer', } #...snip... diff --git a/knox/auth.py b/knox/auth.py index 33341d71..c6526234 100644 --- a/knox/auth.py +++ b/knox/auth.py @@ -1,16 +1,20 @@ from django.conf import settings -from django.contrib.auth import get_user_model from django.utils.translation import ugettext_lazy as _ from django.utils import timezone from rest_framework import exceptions -from rest_framework.authentication import BaseAuthentication, get_authorization_header +from rest_framework.authentication import ( + BaseAuthentication, + get_authorization_header +) from knox.crypto import hash_token from knox.models import AuthToken +from knox.settings import CONSTANTS User = settings.AUTH_USER_MODEL + class TokenAuthentication(BaseAuthentication): ''' This authentication scheme uses Knox AuthTokens for authentication. @@ -30,15 +34,20 @@ def authenticate(self, request): if not auth or auth[0].lower() != b'token': return None - if len(auth) == 1: msg = _('Invalid token header. No credentials provided.') raise exceptions.AuthenticationFailed(msg) elif len(auth) > 2: - msg = _('Invalid token header. Token string should not contain spaces.') + msg = _('Invalid token header. ' + 'Token string should not contain spaces.') raise exceptions.AuthenticationFailed(msg) - return self.authenticate_credentials(auth[1]) + user, auth_token = self.authenticate_credentials(auth[1]) + # For a smooth migration to enforce the token_key + if not auth_token.token_key: + auth_token.token_key = auth[1][:CONSTANTS.TOKEN_KEY_LENGTH] + auth_token.save() + return (user, auth_token) def authenticate_credentials(self, token): ''' @@ -60,8 +69,8 @@ def authenticate_credentials(self, token): def validate_user(self, auth_token): if not auth_token.user.is_active: - raise exceptions.AuthenticationFailed(_('User inactive or deleted.')) - + raise exceptions.AuthenticationFailed( + _('User inactive or deleted.')) return (auth_token.user, auth_token) def authenticate_header(self, request): diff --git a/knox/crypto.py b/knox/crypto.py index ed9ded20..1d3f6487 100644 --- a/knox/crypto.py +++ b/knox/crypto.py @@ -8,21 +8,17 @@ sha = knox_settings.SECURE_HASH_ALGORITHM + def create_token_string(): - return ( - binascii.hexlify( - generate_bytes( - int(knox_settings.AUTH_TOKEN_CHARACTER_LENGTH/2) - ) - ).decode()) + return binascii.hexlify( + generate_bytes(int(knox_settings.AUTH_TOKEN_CHARACTER_LENGTH / 2)) + ).decode() + def create_salt_string(): - return ( - binascii.hexlify( - generate_bytes( - int(CONSTANTS.SALT_LENGTH/2) - ) - ).decode()) + return binascii.hexlify( + generate_bytes(int(CONSTANTS.SALT_LENGTH / 2))).decode() + def hash_token(token, salt): ''' diff --git a/knox/migrations/0005_authtoken_token_key.py b/knox/migrations/0005_authtoken_token_key.py new file mode 100644 index 00000000..5e117246 --- /dev/null +++ b/knox/migrations/0005_authtoken_token_key.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-08-18 09:23 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('knox', '0004_authtoken_expires'), + ] + + operations = [ + migrations.AddField( + model_name='authtoken', + name='token_key', + field=models.CharField(blank=True, db_index=True, max_length=8, null=True), + ), + ] diff --git a/knox/models.py b/knox/models.py index ba130894..f1525e1d 100644 --- a/knox/models.py +++ b/knox/models.py @@ -7,6 +7,7 @@ User = settings.AUTH_USER_MODEL + class AuthTokenManager(models.Manager): def create(self, user, expires=knox_settings.TOKEN_TTL): token = crypto.create_token_string() @@ -14,20 +15,30 @@ def create(self, user, expires=knox_settings.TOKEN_TTL): digest = crypto.hash_token(token, salt) if expires is not None: - expires = timezone.now() + expires + expires = timezone.now() + expires + + super(AuthTokenManager, self).create( + token_key=token[:CONSTANTS.TOKEN_KEY_LENGTH], digest=digest, + salt=salt, user=user, expires=expires) + # Note only the token - not the AuthToken object - is returned + return token - auth_token = super(AuthTokenManager, self).create(digest=digest, salt=salt, user=user, expires=expires) - return token # Note only the token - not the AuthToken object - is returned class AuthToken(models.Model): objects = AuthTokenManager() - digest = models.CharField(max_length=CONSTANTS.DIGEST_LENGTH, primary_key=True) - salt = models.CharField(max_length=CONSTANTS.SALT_LENGTH, unique=True) - user = models.ForeignKey(User, null=False, blank=False, related_name="auth_token_set") + digest = models.CharField( + max_length=CONSTANTS.DIGEST_LENGTH, primary_key=True) + token_key = models.CharField( + max_length=CONSTANTS.TOKEN_KEY_LENGTH, db_index=True, + null=True, blank=True) + salt = models.CharField( + max_length=CONSTANTS.SALT_LENGTH, unique=True) + user = models.ForeignKey( + User, null=False, blank=False, related_name='auth_token_set') created = models.DateTimeField(auto_now_add=True) expires = models. DateTimeField(null=True, blank=True) def __str__(self): - return "%s : %s" % (self.digest, self.user) + return '%s : %s' % (self.digest, self.user) diff --git a/knox/serializers.py b/knox/serializers.py index f11ca691..9c77897b 100644 --- a/knox/serializers.py +++ b/knox/serializers.py @@ -4,7 +4,9 @@ User = get_user_model() +username_field = User.USERNAME_FIELD if hasattr(User, 'USERNAME_FIELD') else 'username' + class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ('username', 'first_name', 'last_name',) + fields = (username_field, 'first_name', 'last_name',) diff --git a/knox/settings.py b/knox/settings.py index 2a2791a2..c40ae884 100644 --- a/knox/settings.py +++ b/knox/settings.py @@ -19,6 +19,7 @@ knox_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) + def reload_api_settings(*args, **kwargs): global knox_settings setting, value = kwargs['setting'], kwargs['value'] @@ -27,17 +28,18 @@ def reload_api_settings(*args, **kwargs): setting_changed.connect(reload_api_settings) + class CONSTANTS: ''' Constants cannot be changed at runtime ''' + TOKEN_KEY_LENGTH = 8 DIGEST_LENGTH = 128 SALT_LENGTH = 16 - def __setattr__ (self, *_, **__): + def __setattr__(self, *args, **kwargs): raise RuntimeException(''' Constant values must NEVER be changed at runtime, as they are integral to the structure of database tables - ''' - ) + ''') CONSTANTS = CONSTANTS() diff --git a/knox/views.py b/knox/views.py index 54682487..361cae3b 100644 --- a/knox/views.py +++ b/knox/views.py @@ -1,6 +1,6 @@ +from django.contrib.auth.signals import user_logged_in, user_logged_out from rest_framework import status -from rest_framework.authentication import BasicAuthentication -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.views import APIView @@ -9,7 +9,6 @@ from knox.models import AuthToken from knox.settings import knox_settings -UserSerializer = knox_settings.USER_SERIALIZER class LoginView(APIView): authentication_classes = api_settings.DEFAULT_AUTHENTICATION_CLASSES @@ -17,19 +16,24 @@ class LoginView(APIView): def post(self, request, format=None): token = AuthToken.objects.create(request.user) + user_logged_in.send(sender=request.user.__class__, request=request, user=request.user) + UserSerializer = knox_settings.USER_SERIALIZER return Response({ - "user": UserSerializer(request.user).data, - "token": token, + 'user': UserSerializer(request.user).data, + 'token': token, }) + class LogoutView(APIView): authentication_classes = (TokenAuthentication,) permission_classes = (IsAuthenticated,) def post(self, request, format=None): request._auth.delete() + user_logged_out.send(sender=request.user.__class__, request=request, user=request.user) return Response(None, status=status.HTTP_204_NO_CONTENT) + class LogoutAllView(APIView): ''' Log the user out of all sessions @@ -40,4 +44,5 @@ class LogoutAllView(APIView): def post(self, request, format=None): request.user.auth_token_set.all().delete() + user_logged_out.send(sender=request.user.__class__, request=request, user=request.user) return Response(None, status=status.HTTP_204_NO_CONTENT) diff --git a/knox_project/urls.py b/knox_project/urls.py index a2af32a2..cc2692ee 100644 --- a/knox_project/urls.py +++ b/knox_project/urls.py @@ -16,8 +16,11 @@ from django.conf.urls import include, url from django.contrib import admin +from .views import RootView + urlpatterns = [ url(r'^api/', include('knox.urls')), + url(r'^api/$', RootView.as_view(), name="api-root"), url(r'^admin/', include(admin.site.urls)), url(r'^', include(admin.site.urls)), ] diff --git a/knox_project/views.py b/knox_project/views.py new file mode 100644 index 00000000..add8505b --- /dev/null +++ b/knox_project/views.py @@ -0,0 +1,12 @@ +from knox.auth import TokenAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + + +class RootView(APIView): + authentication_classes = (TokenAuthentication,) + permission_classes = (IsAuthenticated,) + + def get(self, request): + return Response("api root") diff --git a/mkdocs.yml b/mkdocs.yml index a5f1eddd..435e628e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,6 +7,6 @@ pages: - API Guide: - Views: 'views.md' - URLs: 'urls.md' - - Authentcation: 'auth.md' + - Authentication: 'auth.md' - Settings: 'settings.md' - Changes: 'changes.md' diff --git a/setup.py b/setup.py index 12a1a681..a102da00 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version='2.2.0', + version='2.2.1', description='Authentication for django rest framework', long_description=long_description, @@ -56,13 +56,14 @@ # You can just specify the packages manually here if your project is # simple. Or you can use find_packages(). - packages=find_packages(exclude=['contrib', 'docs', 'tests*', 'knox_project']), + packages=find_packages( + exclude=['contrib', 'docs', 'tests*', 'knox_project']), # List run-time dependencies here. These will be installed by pip when # your project is installed. For an analysis of "install_requires" vs pip's # requirements files see: # https://packaging.python.org/en/latest/requirements.html - install_requires=['django', 'djangorestframework', 'pyOpenSSL',], + install_requires=['django', 'djangorestframework', 'pyOpenSSL'], # List additional groups of dependencies here (e.g. development # dependencies). You can install these using the following syntax, diff --git a/tests/tests.py b/tests/tests.py index 0c45520a..ea6f2dcd 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,17 +1,21 @@ -import json import base64 import datetime from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse -from rest_framework.test import APITestCase as TestCase +from rest_framework.test import APIRequestFactory, APITestCase as TestCase +from knox.auth import TokenAuthentication from knox.models import AuthToken +from knox.settings import CONSTANTS User = get_user_model() + def get_basic_auth_header(username, password): - return 'Basic %s' % base64.b64encode(('%s:%s' % (username, password)).encode('ascii')).decode() + return 'Basic %s' % base64.b64encode( + ('%s:%s' % (username, password)).encode('ascii')).decode() + class AuthTestCase(TestCase): @@ -20,16 +24,19 @@ def test_login_creates_keys(self): username, password = 'root', 'toor' User.objects.create_user(username, 'root@localhost.com', password) url = reverse('knox_login') - self.client.credentials(HTTP_AUTHORIZATION=get_basic_auth_header(username, password)) + self.client.credentials( + HTTP_AUTHORIZATION=get_basic_auth_header(username, password)) for _ in range(5): self.client.post(url, {}, format='json') self.assertEqual(AuthToken.objects.count(), 5) + self.assertTrue(all(e.token_key for e in AuthToken.objects.all())) def test_logout_deletes_keys(self): self.assertEqual(AuthToken.objects.count(), 0) username, password = 'root', 'toor' - user = User.objects.create_user(username, 'root@localhost.com', password) + user = User.objects.create_user( + username, 'root@localhost.com', password) token = AuthToken.objects.create(user=user) self.assertEqual(AuthToken.objects.count(), 1) @@ -41,7 +48,8 @@ def test_logout_deletes_keys(self): def test_logout_all_deletes_keys(self): self.assertEqual(AuthToken.objects.count(), 0) username, password = 'root', 'toor' - user = User.objects.create_user(username, 'root@localhost.com', password) + user = User.objects.create_user( + username, 'root@localhost.com', password) for _ in range(10): token = AuthToken.objects.create(user=user) self.assertEqual(AuthToken.objects.count(), 10) @@ -54,9 +62,12 @@ def test_logout_all_deletes_keys(self): def test_expired_tokens_deleted(self): self.assertEqual(AuthToken.objects.count(), 0) username, password = 'root', 'toor' - user = User.objects.create_user(username, 'root@localhost.com', password) + user = User.objects.create_user( + username, 'root@localhost.com', password) for _ in range(10): - token = AuthToken.objects.create(user=user, expires=datetime.timedelta(seconds=0)) #0 TTL gives an expired token + # 0 TTL gives an expired token + token = AuthToken.objects.create( + user=user, expires=datetime.timedelta(seconds=0)) self.assertEqual(AuthToken.objects.count(), 10) # Attempting a single logout should delete all tokens @@ -65,3 +76,29 @@ def test_expired_tokens_deleted(self): self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) self.client.post(url, {}, format='json') self.assertEqual(AuthToken.objects.count(), 0) + + def test_update_token_key(self): + self.assertEqual(AuthToken.objects.count(), 0) + username, password = 'root', 'toor' + user = User.objects.create_user( + username, 'root@localhost.com', password) + token = AuthToken.objects.create(user) + auth_token = AuthToken.objects.first() + auth_token.token_key = None + auth_token.save() + rf = APIRequestFactory() + request = rf.get('/') + request.META = {'HTTP_AUTHORIZATION': 'Token {}'.format(token)} + TokenAuthentication().authenticate(request) + auth_token = AuthToken.objects.get(digest=auth_token.digest) + self.assertEqual( + token[:CONSTANTS.TOKEN_KEY_LENGTH], + auth_token.token_key) + + def test_invalid_token_length_returns_401_code(self): + invalid_token = "1" * (CONSTANTS.TOKEN_KEY_LENGTH - 1) + url = reverse('api-root') + self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % invalid_token)) + response = self.client.post(url, {}, format='json') + self.assertEqual(response.status_code, 401) + self.assertEqual(response.data, {"detail": "Invalid token."})