From df68e0ed6468f32d9d944205630be08424839c82 Mon Sep 17 00:00:00 2001 From: abdelbaki1 Date: Thu, 25 Aug 2022 00:21:54 +0100 Subject: [PATCH] Add cookie-based authentication feature --- docs/settings.md | 15 +++++++++++++ knox/auth.py | 18 +++++++++++++++- knox/settings.py | 9 ++++++++ knox/views.py | 35 ++++++++++++++++++++++++++---- tests/tests.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 5 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index 0ba2317d..b6cc530c 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -101,6 +101,15 @@ The default is `knox.AuthToken` ## TOKEN_PREFIX This is the prefix for the generated token that is used in the Authorization header. The default is just an empty string. It can be up to `CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH` long. +## ENABLE_COOKIE_AUTH +This defines if the authentication will be done using a `HTTP-only` Cookie. +The default is `False` +## AUTH_COOKIE_SALT +This is the Salt `(String)` that is added to better store and secure token value inside Cookies +the default value is `"knox"` and it can be up to `CONSTANTS.MAXIMUM_SALT_LENGTH` long. +## AUTH_COOKIE_KEY +This is the `Cookie` key of the token. The default is `knox`. +It can be up to `CONSTANTS.MAXIMUM_COOKIE_KEY_LENGTH` long. # Constants `knox.settings` Knox also provides some constants for information. These must not be changed in @@ -118,3 +127,9 @@ This is the length of the digest that will be stored in the database for each to ## MAXIMUM_TOKEN_PREFIX_LENGTH This is the maximum length of the token prefix. + +## MAXIMUM_COOKIE_KEY_LENGTH +This is the maximum length of the cookie `key` + +## MAXIMUM_SALT_LENGTH +This is the maximum length of Salt `(String)` that encode token value inside the cookie. diff --git a/knox/auth.py b/knox/auth.py index a7b5f873..189483f6 100644 --- a/knox/auth.py +++ b/knox/auth.py @@ -31,8 +31,24 @@ class TokenAuthentication(BaseAuthentication): - `request.user` will be a django `User` instance - `request.auth` will be an `AuthToken` instance ''' - + def authenticate_through_cookie(self, request): + prefix = knox_settings.AUTH_COOKIE_KEY + if prefix in request.COOKIES: + auth =request.get_signed_cookie(prefix,False,salt=knox_settings.AUTH_COOKIE_SALT) + if not auth or len(auth) == 0: + msg = _('Failed to get token value from cookies') + raise exceptions.AuthenticationFailed(msg) + user, auth_token = self.authenticate_credentials(auth.encode()) + return (user, auth_token) + else : + msg = _('No credentials provided through cookies') + raise exceptions.AuthenticationFailed(msg) + def authenticate(self, request): + + if knox_settings.ENABLE_COOKIE_AUTH: + return self.authenticate_through_cookie(request) + auth = get_authorization_header(request).split() prefix = knox_settings.AUTH_HEADER_PREFIX.encode() diff --git a/knox/settings.py b/knox/settings.py index a5def499..d55fdcf7 100644 --- a/knox/settings.py +++ b/knox/settings.py @@ -18,6 +18,9 @@ 'EXPIRY_DATETIME_FORMAT': api_settings.DATETIME_FORMAT, 'TOKEN_MODEL': getattr(settings, 'KNOX_TOKEN_MODEL', 'knox.AuthToken'), 'TOKEN_PREFIX': '', + 'ENABLE_COOKIE_AUTH':False, + 'AUTH_COOKIE_SALT':"knox", + 'AUTH_COOKIE_KEY': 'knox', } IMPORT_STRINGS = { @@ -35,6 +38,10 @@ def reload_api_settings(*args, **kwargs): knox_settings = APISettings(value, DEFAULTS, IMPORT_STRINGS) if len(knox_settings.TOKEN_PREFIX) > CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH: raise ValueError("Illegal TOKEN_PREFIX length") + if len(knox_settings.AUTH_COOKIE_KEY) > CONSTANTS.MAXIMUM_COOKIE_KEY_LENGTH: + raise ValueError("unauthorized COOKIE_KEY length") + if len(knox_settings.AUTH_COOKIE_SALT) > CONSTANTS.MAXIMUM_SALT_LENGTH: + raise ValueError("unauthorized COOKIE_SALT length") setting_changed.connect(reload_api_settings) @@ -47,6 +54,8 @@ class CONSTANTS: TOKEN_KEY_LENGTH = 15 DIGEST_LENGTH = 128 MAXIMUM_TOKEN_PREFIX_LENGTH = 10 + MAXIMUM_SALT_LENGTH=10 + MAXIMUM_COOKIE_KEY_LENGTH=8 def __setattr__(self, *args, **kwargs): raise Exception(''' diff --git a/knox/views.py b/knox/views.py index 7a6b5719..8c3fdc1f 100644 --- a/knox/views.py +++ b/knox/views.py @@ -33,6 +33,14 @@ def get_user_serializer_class(self): def get_expiry_datetime_format(self): return knox_settings.EXPIRY_DATETIME_FORMAT + def get_cookie_auth_status(self): + return knox_settings.ENABLE_COOKIE_AUTH + + def get_cookie_salt(self): + return knox_settings.AUTH_COOKIE_SALT + + def get_cookie_key(self): + return knox_settings.AUTH_COOKIE_KEY def format_expiry_datetime(self, expiry): datetime_format = self.get_expiry_datetime_format() @@ -72,18 +80,27 @@ def post(self, request, format=None): user_logged_in.send(sender=request.user.__class__, request=request, user=request.user) data = self.get_post_response_data(request, token, instance) - return Response(data) - + response=Response(data) + if self.get_cookie_auth_status(): + response.set_signed_cookie(self.get_cookie_key(), token, httponly=True,salt=self.get_cookie_salt()) + return response class LogoutView(APIView): authentication_classes = (TokenAuthentication,) permission_classes = (IsAuthenticated,) + def get_cookie_auth_status(self): + return knox_settings.ENABLE_COOKIE_AUTH + def get_cookie_key(self): + return knox_settings.AUTH_COOKIE_KEY 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) + response=Response(None, status=status.HTTP_204_NO_CONTENT) + if self.get_cookie_auth_status(): + response.delete_cookie(self.get_cookie_key()) + return response class LogoutAllView(APIView): @@ -94,8 +111,18 @@ class LogoutAllView(APIView): authentication_classes = (TokenAuthentication,) permission_classes = (IsAuthenticated,) + def get_cookie_auth_status(self): + return knox_settings.ENABLE_COOKIE_AUTH + + def get_cookie_key(self): + return knox_settings.AUTH_COOKIE_KEY + 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) + response=Response(None, status=status.HTTP_204_NO_CONTENT) + if self.get_cookie_auth_status(): + response.delete_cookie(self.get_cookie_key()) + return response + diff --git a/tests/tests.py b/tests/tests.py index 574305c2..25fe765d 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -53,6 +53,10 @@ def get_basic_auth_header(username, password): token_prefix_too_long_knox = knox_settings.defaults.copy() token_prefix_too_long_knox["TOKEN_PREFIX"] = token_prefix_too_long +cookie_key='cookie' +cookie_key_knox=knox_settings.defaults.copy() +cookie_key_knox['AUTH_COOKIE_KEY']=cookie_key + class AuthTestCase(TestCase): def setUp(self): @@ -488,3 +492,54 @@ def test_tokens_created_before_prefix_still_work(self): response = self.client.get(root_url, {}, format='json') self.assertEqual(response.status_code, 200) reload_module(views) + def test_login_create_token_cookie(self): + with override_settings(REST_KNOX=cookie_key_knox): + reload_module(auth) + reload_module(views) + self.assertEqual(AuthToken.objects.count(), 0) + url = reverse('knox_login') + self.client.credentials( + HTTP_AUTHORIZATION=get_basic_auth_header(self.username, self.password) + ) + response = self.client.post(url, {}, format='json') + self.assertEqual(response.status_code, 200) + self.assertIn(cookie_key_knox["AUTH_COOKIE_KEY"], self.client.cookies.keys()) + reload_module(auth) + response = self.client.get(root_url, {}, format='json') + self.assertEqual(response.status_code, 200) + reload_module(views) + + def test_logout_deletes_token_cookie(self): + with override_settings(REST_KNOX=cookie_key_knox): + reload_module(auth) + reload_module(views) + self.assertEqual(AuthToken.objects.count(), 0) + for _ in range(2): + instance, token = AuthToken.objects.create(user=self.user) + self.assertEqual(AuthToken.objects.count(), 2) + + url = reverse('knox_logout') + + self.client.post(url, {}, format='json') + self.assertEqual(AuthToken.objects.count(), 1, + 'other tokens should remain after logout') + self.assertNotIn(cookie_key_knox["AUTH_COOKIE_KEY"], self.client.cookies.keys()) + reload_module(auth) + reload_module(views) + + def test_logout_all_deletes_token_cookie(self): + with override_settings(REST_KNOX=cookie_key_knox): + reload_module(auth) + reload_module(views) + self.assertEqual(AuthToken.objects.count(), 0) + for _ in range(10): + instance, token = AuthToken.objects.create(user=self.user) + self.assertEqual(AuthToken.objects.count(), 10) + + url = reverse('knox_logoutall') + self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) + self.client.post(url, {}, format='json') + self.assertEqual(AuthToken.objects.count(), 0) + self.assertNotIn(cookie_key_knox["AUTH_COOKIE_KEY"], self.client.cookies.keys()) + reload_module(auth) + reload_module(views)