From 475e0b94c2cb00e9c260cd944e91d850627fd211 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 22 Mar 2020 05:41:16 -0500 Subject: [PATCH] Support for Http-Only JWT Cookies --- demo/requirements.pip | 1 + dev-requirements.txt | 2 +- dj_rest_auth/tests/requirements.pip | 3 +- dj_rest_auth/tests/test_api.py | 299 +++------------------------- dj_rest_auth/tests/test_social.py | 3 - dj_rest_auth/tests/urls.py | 12 ++ dj_rest_auth/utils.py | 15 +- dj_rest_auth/views.py | 21 +- docs/configuration.rst | 2 +- 9 files changed, 64 insertions(+), 294 deletions(-) diff --git a/demo/requirements.pip b/demo/requirements.pip index 7442229..6d24967 100644 --- a/demo/requirements.pip +++ b/demo/requirements.pip @@ -1,5 +1,6 @@ django>=1.9.0 dj-rest-auth==0.1.4 djangorestframework>=3.7.0 +djangorestframework-simplejwt==4.4.0 django-allauth>=0.24.1 django-rest-swagger==2.0.7 diff --git a/dev-requirements.txt b/dev-requirements.txt index 9d2af20..cee8ea6 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,5 @@ --editable . responses>=0.5.0 -djangorestframework-jwt +djangorestframework-simplejwt==4.4.0 django-allauth coveralls>=1.11.1 \ No newline at end of file diff --git a/dj_rest_auth/tests/requirements.pip b/dj_rest_auth/tests/requirements.pip index f48ee3c..4ff9b2d 100644 --- a/dj_rest_auth/tests/requirements.pip +++ b/dj_rest_auth/tests/requirements.pip @@ -1,5 +1,6 @@ django-allauth>=0.25.0 responses>=0.3.0 flake8==2.4.0 -djangorestframework-jwt>=1.7.2 +Django==3.0.4 +djangorestframework-simplejwt==4.4.0 djangorestframework>=3.6.4 diff --git a/dj_rest_auth/tests/test_api.py b/dj_rest_auth/tests/test_api.py index 5d06fac..4519186 100644 --- a/dj_rest_auth/tests/test_api.py +++ b/dj_rest_auth/tests/test_api.py @@ -144,293 +144,46 @@ class APIBasicTests(TestsMixin, TestCase): self.post(self.login_url, data=payload, status_code=200) @override_settings(REST_USE_JWT=True) - def test_login_jwt(self): + @override_settings(JWT_AUTH_COOKIE='jwt-auth') + def test_login_jwt_sets_cookie(self): payload = { "username": self.USERNAME, "password": self.PASS } get_user_model().objects.create_user(self.USERNAME, '', self.PASS) - - self.post(self.login_url, data=payload, status_code=200) - self.assertEqual('access_token' in self.response.json.keys(), True) - self.token = self.response.json['access_token'] - - def test_login_by_email(self): - # starting test without allauth app - settings.INSTALLED_APPS.remove('allauth') - - payload = { - "email": self.EMAIL.lower(), - "password": self.PASS - } - # there is no users in db so it should throw error (400) - self.post(self.login_url, data=payload, status_code=400) - - self.post(self.password_change_url, status_code=403) - - # create user - user = get_user_model().objects.create_user(self.USERNAME, self.EMAIL, self.PASS) - - # test auth by email - self.post(self.login_url, data=payload, status_code=200) - self.assertEqual('key' in self.response.json.keys(), True) - self.token = self.response.json['key'] - - # test auth by email in different case - payload = { - "email": self.EMAIL.upper(), - "password": self.PASS - } - self.post(self.login_url, data=payload, status_code=200) - self.assertEqual('key' in self.response.json.keys(), True) - self.token = self.response.json['key'] - - # test inactive user - user.is_active = False - user.save() - self.post(self.login_url, data=payload, status_code=400) - - # test wrong email/password - payload = { - "email": 't' + self.EMAIL, - "password": self.PASS - } - self.post(self.login_url, data=payload, status_code=400) - - # test empty payload - self.post(self.login_url, data={}, status_code=400) - - # bring back allauth - settings.INSTALLED_APPS.append('allauth') - - def test_password_change(self): - login_payload = { - "username": self.USERNAME, - "password": self.PASS - } - get_user_model().objects.create_user(self.USERNAME, '', self.PASS) - self.post(self.login_url, data=login_payload, status_code=200) - self.token = self.response.json['key'] - - new_password_payload = { - "new_password1": "new_person", - "new_password2": "new_person" - } - self.post( - self.password_change_url, - data=new_password_payload, - status_code=200 - ) - - # user should not be able to login using old password - self.post(self.login_url, data=login_payload, status_code=400) - - # new password should work - login_payload['password'] = new_password_payload['new_password1'] - self.post(self.login_url, data=login_payload, status_code=200) - - # pass1 and pass2 are not equal - new_password_payload = { - "new_password1": "new_person1", - "new_password2": "new_person" - } - self.post( - self.password_change_url, - data=new_password_payload, - status_code=400 - ) - - # send empty payload - self.post(self.password_change_url, data={}, status_code=400) - - @override_settings(OLD_PASSWORD_FIELD_ENABLED=True) - def test_password_change_with_old_password(self): - login_payload = { - "username": self.USERNAME, - "password": self.PASS - } - get_user_model().objects.create_user(self.USERNAME, '', self.PASS) - self.post(self.login_url, data=login_payload, status_code=200) - self.token = self.response.json['key'] - - new_password_payload = { - "old_password": "%s!" % self.PASS, # wrong password - "new_password1": "new_person", - "new_password2": "new_person" - } - self.post( - self.password_change_url, - data=new_password_payload, - status_code=400 - ) - - new_password_payload = { - "old_password": self.PASS, - "new_password1": "new_person", - "new_password2": "new_person" - } - self.post( - self.password_change_url, - data=new_password_payload, - status_code=200 - ) - - # user should not be able to login using old password - self.post(self.login_url, data=login_payload, status_code=400) - - # new password should work - login_payload['password'] = new_password_payload['new_password1'] - self.post(self.login_url, data=login_payload, status_code=200) - - def test_password_reset(self): - user = get_user_model().objects.create_user(self.USERNAME, self.EMAIL, self.PASS) - - # call password reset - mail_count = len(mail.outbox) - payload = {'email': self.EMAIL} - self.post(self.password_reset_url, data=payload, status_code=200) - self.assertEqual(len(mail.outbox), mail_count + 1) - - url_kwargs = self._generate_uid_and_token(user) - url = reverse('rest_password_reset_confirm') - - # wrong token - data = { - 'new_password1': self.NEW_PASS, - 'new_password2': self.NEW_PASS, - 'uid': force_text(url_kwargs['uid']), - 'token': '-wrong-token-' - } - self.post(url, data=data, status_code=400) - - # wrong uid - data = { - 'new_password1': self.NEW_PASS, - 'new_password2': self.NEW_PASS, - 'uid': '-wrong-uid-', - 'token': url_kwargs['token'] - } - self.post(url, data=data, status_code=400) - - # wrong token and uid - data = { - 'new_password1': self.NEW_PASS, - 'new_password2': self.NEW_PASS, - 'uid': '-wrong-uid-', - 'token': '-wrong-token-' - } - self.post(url, data=data, status_code=400) - - # valid payload - data = { - 'new_password1': self.NEW_PASS, - 'new_password2': self.NEW_PASS, - 'uid': force_text(url_kwargs['uid']), - 'token': url_kwargs['token'] - } - url = reverse('rest_password_reset_confirm') - self.post(url, data=data, status_code=200) - - payload = { - "username": self.USERNAME, - "password": self.NEW_PASS - } - self.post(self.login_url, data=payload, status_code=200) - - def test_password_reset_with_email_in_different_case(self): - get_user_model().objects.create_user(self.USERNAME, self.EMAIL.lower(), self.PASS) - - # call password reset in upper case - mail_count = len(mail.outbox) - payload = {'email': self.EMAIL.upper()} - self.post(self.password_reset_url, data=payload, status_code=200) - self.assertEqual(len(mail.outbox), mail_count + 1) - - def test_password_reset_with_invalid_email(self): - """ - Invalid email should not raise error, as this would leak users - """ - get_user_model().objects.create_user(self.USERNAME, self.EMAIL, self.PASS) - - # call password reset - mail_count = len(mail.outbox) - payload = {'email': 'nonexisting@email.com'} - self.post(self.password_reset_url, data=payload, status_code=200) - self.assertEqual(len(mail.outbox), mail_count) - - def test_user_details(self): - user = get_user_model().objects.create_user(self.USERNAME, self.EMAIL, self.PASS) - payload = { - "username": self.USERNAME, - "password": self.PASS - } - self.post(self.login_url, data=payload, status_code=200) - self.token = self.response.json['key'] - self.get(self.user_url, status_code=200) - - self.patch(self.user_url, data=self.BASIC_USER_DATA, status_code=200) - user = get_user_model().objects.get(pk=user.pk) - self.assertEqual(user.first_name, self.response.json['first_name']) - self.assertEqual(user.last_name, self.response.json['last_name']) - self.assertEqual(user.email, self.response.json['email']) + resp = self.post(self.login_url, data=payload, status_code=200) + self.assertTrue('jwt-auth' in resp.cookies.keys()) @override_settings(REST_USE_JWT=True) - def test_user_details_using_jwt(self): - user = get_user_model().objects.create_user(self.USERNAME, self.EMAIL, self.PASS) + @override_settings(JWT_AUTH_COOKIE='jwt-auth') + def test_logout_jwt_deletes_cookie(self): payload = { "username": self.USERNAME, "password": self.PASS } + get_user_model().objects.create_user(self.USERNAME, '', self.PASS) self.post(self.login_url, data=payload, status_code=200) - self.token = self.response.json['access_token'] - self.get(self.user_url, status_code=200) - - self.patch(self.user_url, data=self.BASIC_USER_DATA, status_code=200) - user = get_user_model().objects.get(pk=user.pk) - self.assertEqual(user.email, self.response.json['email']) - - def test_registration(self): - user_count = get_user_model().objects.all().count() - - # test empty payload - self.post(self.register_url, data={}, status_code=400) - - result = self.post(self.register_url, data=self.REGISTRATION_DATA, status_code=201) - self.assertIn('key', result.data) - self.assertEqual(get_user_model().objects.all().count(), user_count + 1) - - new_user = get_user_model().objects.latest('id') - self.assertEqual(new_user.username, self.REGISTRATION_DATA['username']) - - self._login() - self._logout() - - @override_settings(REST_AUTH_REGISTER_PERMISSION_CLASSES=(CustomPermissionClass,)) - def test_registration_with_custom_permission_class(self): - - class CustomRegisterView(RegisterView): - permission_classes = register_permission_classes() - authentication_classes = () - - factory = APIRequestFactory() - request = factory.post('/customer/details', self.REGISTRATION_DATA, format='json') - - response = CustomRegisterView.as_view()(request) - self.assertEqual(response.data['detail'], CustomPermissionClass.message) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + resp = self.post(self.logout_url, status=200) + self.assertEqual('', resp.cookies.get('jwt-auth').value) @override_settings(REST_USE_JWT=True) - def test_registration_with_jwt(self): - user_count = get_user_model().objects.all().count() - - self.post(self.register_url, data={}, status_code=400) - - result = self.post(self.register_url, data=self.REGISTRATION_DATA, status_code=201) - self.assertIn('access_token', result.data) - self.assertEqual(get_user_model().objects.all().count(), user_count + 1) - - self._login() - self._logout() + @override_settings(JWT_AUTH_COOKIE='jwt-auth') + @override_settings(REST_FRAMEWORK=dict( + DEFAULT_AUTHENTICATION_CLASSES=[ + 'dj_rest_auth.utils.JWTCookieAuthentication' + ] + )) + @override_settings(REST_SESSION_LOGIN=False) + def test_cookie_authentication(self): + payload = { + "username": self.USERNAME, + "password": self.PASS + } + get_user_model().objects.create_user(self.USERNAME, '', self.PASS) + resp = self.post(self.login_url, data=payload, status_code=200) + self.assertEqual(['jwt-auth'], list(resp.cookies.keys())) + resp = self.get('/protected-view/') + self.assertEquals(resp.status_code, 200) def test_registration_with_invalid_password(self): data = self.REGISTRATION_DATA.copy() diff --git a/dj_rest_auth/tests/test_social.py b/dj_rest_auth/tests/test_social.py index cceba3f..819b153 100644 --- a/dj_rest_auth/tests/test_social.py +++ b/dj_rest_auth/tests/test_social.py @@ -17,9 +17,6 @@ except ImportError: from django.core.urlresolvers import reverse - - - @override_settings(ROOT_URLCONF="tests.urls") class TestSocialAuth(TestsMixin, TestCase): diff --git a/dj_rest_auth/tests/urls.py b/dj_rest_auth/tests/urls.py index be199a5..7579e83 100644 --- a/dj_rest_auth/tests/urls.py +++ b/dj_rest_auth/tests/urls.py @@ -11,10 +11,21 @@ from dj_rest_auth.urls import urlpatterns from django.conf.urls import include, url from django.views.generic import TemplateView from rest_framework.decorators import api_view +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import permissions from . import django_urls +class ExampleProtectedView(APIView): + permission_classes = [permissions.IsAuthenticated] + + + def get(self, *args, **kwargs): + return Response(dict(success=True)) + + class FacebookLogin(SocialLoginView): adapter_class = FacebookOAuth2Adapter @@ -64,6 +75,7 @@ urlpatterns += [ url(r'^social-login/facebook/connect/$', FacebookConnect.as_view(), name='fb_connect'), url(r'^social-login/twitter/connect/$', TwitterConnect.as_view(), name='tw_connect'), url(r'^socialaccounts/$', SocialAccountListView.as_view(), name='social_account_list'), + url(r'^protected-view/$', ExampleProtectedView.as_view()), url(r'^socialaccounts/(?P\d+)/disconnect/$', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'), url(r'^accounts/', include('allauth.socialaccount.urls')) diff --git a/dj_rest_auth/utils.py b/dj_rest_auth/utils.py index 2b8094a..7cbf3ad 100644 --- a/dj_rest_auth/utils.py +++ b/dj_rest_auth/utils.py @@ -1,5 +1,5 @@ from importlib import import_module -from .app_settings import JWT_AUTH_COOKIE + def import_callable(path_or_callable): if hasattr(path_or_callable, '__call__'): @@ -31,13 +31,16 @@ try: class JWTCookieAuthentication(JWTAuthentication): """ An authentication plugin that hopefully authenticates requests through a JSON web - token provided in a request cookie (and through the header as normal, with a preference to the header). + token provided in a request cookie (and through the header as normal, with a + preference to the header). """ def authenticate(self, request): + from django.conf import settings + cookie_name = getattr(settings, 'JWT_AUTH_COOKIE', None) header = self.get_header(request) if header is None: - if JWT_AUTH_COOKIE: # or settings.JWT_AUTH_COOKIE - raw_token = request.COOKIES.get(JWT_AUTH_COOKIE) # or settings.jwt_auth_cookie + if cookie_name: + raw_token = request.COOKIES.get(cookie_name) else: return None else: @@ -47,7 +50,7 @@ try: return None validated_token = self.get_validated_token(raw_token) - return self.get_user(validated_token), validated_token -except ImportError as I: + +except ImportError: pass diff --git a/dj_rest_auth/views.py b/dj_rest_auth/views.py index 2896209..a31e879 100644 --- a/dj_rest_auth/views.py +++ b/dj_rest_auth/views.py @@ -16,7 +16,7 @@ from .app_settings import (JWTSerializer, LoginSerializer, PasswordChangeSerializer, PasswordResetConfirmSerializer, PasswordResetSerializer, TokenSerializer, - UserDetailsSerializer, create_token, JWT_AUTH_COOKIE) + UserDetailsSerializer, create_token) from .models import TokenModel from .utils import jwt_encode @@ -84,14 +84,17 @@ class LoginView(GenericAPIView): response = Response(serializer.data, status=status.HTTP_200_OK) if getattr(settings, 'REST_USE_JWT', False): + cookie_name = getattr(settings, 'JWT_AUTH_COOKIE', None) from rest_framework_simplejwt.settings import api_settings as jwt_settings - if JWT_AUTH_COOKIE: #this needs to be added to simplejwt + if cookie_name: from datetime import datetime expiration = (datetime.utcnow() + jwt_settings.ACCESS_TOKEN_LIFETIME) - response.set_cookie(JWT_AUTH_COOKIE, #this needs to be added to simplejwt - self.access_token, - expires=expiration, - httponly=True) + response.set_cookie( + cookie_name, + self.access_token, + expires=expiration, + httponly=True + ) return response def post(self, request, *args, **kwargs): @@ -135,9 +138,9 @@ class LogoutView(APIView): response = Response({"detail": _("Successfully logged out.")}, status=status.HTTP_200_OK) if getattr(settings, 'REST_USE_JWT', False): - # from rest_framework_simplejwt.settings import api_settings as jwt_settings - if JWT_AUTH_COOKIE: #this needs to be added to simplejwt - response.delete_cookie(JWT_AUTH_COOKIE) #this needs to be added to simplejwt + cookie_name = getattr(settings, 'JWT_AUTH_COOKIE') + if cookie_name: + response.delete_cookie(cookie_name) return response diff --git a/docs/configuration.rst b/docs/configuration.rst index af858c0..d4d67cc 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -47,7 +47,7 @@ Configuration - **REST_SESSION_LOGIN** - Enable session login in Login API view (default: True) - **REST_USE_JWT** - Enable JWT Authentication instead of Token/Session based. This is built on top of djangorestframework-simplejwt https://github.com/SimpleJWT/django-rest-framework-simplejwt, which must also be installed. (default: False) - +- **JWT_AUTH_COOKIE** - The cookie name/key. - **OLD_PASSWORD_FIELD_ENABLED** - set it to True if you want to have old password verification on password change enpoint (default: False) - **LOGOUT_ON_PASSWORD_CHANGE** - set to False if you want to keep the current user logged in after a password change