├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── rest_framework_sav ├── __init__.py └── views.py ├── runtests.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── models.py ├── settings.py ├── test_views.py ├── urls.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.6" 4 | - "2.7" 5 | - "3.2" 6 | - "3.3" 7 | - "3.4" 8 | env: 9 | - DJANGO_VERSION=1.6.10 10 | - DJANGO_VERSION=1.7.4 11 | - DJANGO_VERSION=1.8 12 | matrix: 13 | exclude: 14 | - python: "2.6" 15 | env: DJANGO_VERSION=1.7.4 16 | - python: "2.6" 17 | env: DJANGO_VERSION=1.8 18 | fast_finish: true 19 | 20 | install: 21 | - pip install Django==$DJANGO_VERSION djangorestframework==3.1.1 22 | - pip install coveralls 23 | script: 24 | coverage run --source=rest_framework_sav runtests.py 25 | after_success: 26 | coveralls 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, James Ritchie 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Session Authentication View for Django Rest Framework 2 | 3 | [![Build Status](https://travis-ci.org/JamesRitchie/django-rest-framework-sav.svg?branch=master)](https://travis-ci.org/JamesRitchie/django-rest-framework-sav) 4 | [![Coverage Status](https://coveralls.io/repos/JamesRitchie/django-rest-framework-sav/badge.svg?branch=master)](https://coveralls.io/r/JamesRitchie/django-rest-framework-sav?branch=master) 5 | [![Code Health](https://landscape.io/github/JamesRitchie/django-rest-framework-sav/master/landscape.svg?style=flat)](https://landscape.io/github/JamesRitchie/django-rest-framework-sav/master) 6 | [![PyPI version](https://badge.fury.io/py/djangorestframework_sav.svg)](http://badge.fury.io/py/djangorestframework_sav) 7 | 8 | This package extends Django Rest Framework to add a Session Authentication view 9 | , in a similar manner to the `obtain_auth_token` view. 10 | It allows session login and logout in a REST-like manner, ideal if you want to 11 | completely decouple a single-page application from your backend. 12 | 13 | Whilst the package is quite simple, providing one view and reusing the 14 | [serializer](https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/authtoken/serializers.py) 15 | from the included `auth_token` app, it is well tested, making it useful for 16 | production systems using Django Rest Framework 3.0. 17 | The build matrix for testing covers all currently supported versions of Django 18 | and their compatible Python versions. 19 | 20 | ## Installation 21 | 22 | Grab the package using PIP. 23 | 24 | ```zsh 25 | pip install djangorestframework-sav 26 | ``` 27 | 28 | Add `rest_framework_sav` to `INSTALLED_APPS` in `settings.py`. 29 | 30 | ```python 31 | INSTALLED_APPS = [ 32 | ... 33 | 'rest_framework_sav', 34 | ... 35 | ] 36 | ``` 37 | 38 | Make sure 39 | [Session Authentication](http://www.django-rest-framework.org/api-guide/authentication/#sessionauthentication) 40 | is setup correctly. 41 | 42 | In your URLconf, add the view to the endpoint you want it at. 43 | 44 | ```python 45 | from rest_framework_sav.views import session_auth_view 46 | 47 | urlpatterns += [ 48 | url(r'^auth-session/$', session_auth_view) 49 | ] 50 | ``` 51 | 52 | In production, make sure to serve this view only over HTTPS. 53 | 54 | ## Usage 55 | 56 | To login, send a POST request with `username` and `password` fields to the 57 | endpoint. 58 | Successful attempts will return with HTTP status 200, and a JSON message in the 59 | response body. 60 | 61 | ```json 62 | {'detail': 'Session login successful.'} 63 | ``` 64 | 65 | The view will call Django's 66 | [`login`](https://docs.djangoproject.com/en/1.7/topics/auth/default/#django.contrib.auth.login) 67 | method if it passes the 68 | [`authenticate`](https://docs.djangoproject.com/en/1.7/topics/auth/default/#django.contrib.auth.authenticate) method, setting the session cookie on the client, and providing a CSRF token. 69 | Unsuccessful attempts will return with HTTP status 400, and a JSON message with 70 | more detail in the response body. 71 | 72 | To logout, simply send a DELETE request to the endpoint. 73 | The view will call Django's 74 | [`logout`](https://docs.djangoproject.com/en/1.7/topics/auth/default/#django.contrib.auth.logout) 75 | method, invalidating the current session. 76 | Whilst sending a DELETE request without authenticating will not cause an error, 77 | session authentication must be used in order to have an effect, and this will 78 | require a CSRF token to be sent. 79 | 80 | ## Future enhancements 81 | * More informative error status codes other than 400. 82 | * Implement throttle setting. 83 | -------------------------------------------------------------------------------- /rest_framework_sav/__init__.py: -------------------------------------------------------------------------------- 1 | """Package adding Session Authentication view to Django REST Framework.""" 2 | 3 | __all__ = [ 4 | 'views' 5 | ] 6 | 7 | __version__ = '0.1.0' 8 | -------------------------------------------------------------------------------- /rest_framework_sav/views.py: -------------------------------------------------------------------------------- 1 | """Views for Django Rest Framework Session Endpoint extension.""" 2 | 3 | from django.contrib.auth import login, logout 4 | 5 | from rest_framework import parsers, renderers 6 | from rest_framework.authtoken.serializers import AuthTokenSerializer 7 | from rest_framework.response import Response 8 | from rest_framework.views import APIView 9 | 10 | 11 | class SessionAuthView(APIView): 12 | 13 | """Provides methods for REST-like session authentication.""" 14 | 15 | throttle_classes = () 16 | permission_classes = () 17 | parser_classes = ( 18 | parsers.FormParser, 19 | parsers.MultiPartParser, 20 | parsers.JSONParser 21 | ) 22 | renderer_classes = (renderers.JSONRenderer,) 23 | 24 | def post(self, request): 25 | """Login using posted username and password.""" 26 | serializer = AuthTokenSerializer(data=request.data) 27 | serializer.is_valid(raise_exception=True) 28 | user = serializer.validated_data['user'] 29 | login(request, user) 30 | return Response({'detail': 'Session login successful.'}) 31 | 32 | def delete(self, request): 33 | """Logout the current session.""" 34 | logout(request) 35 | return Response({'detail': 'Session logout successful.'}) 36 | 37 | 38 | session_auth_view = SessionAuthView.as_view() 39 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | def run(): 7 | """Run tests with Django setup.""" 8 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 9 | from django.conf import settings 10 | 11 | # Will only work in 1.7 12 | try: 13 | from django import setup 14 | except ImportError: 15 | pass 16 | else: 17 | setup() 18 | 19 | from django.test.utils import get_runner 20 | 21 | TestRunner = get_runner(settings) 22 | test_runner = TestRunner() 23 | failures = test_runner.run_tests(["tests"]) 24 | sys.exit(bool(failures)) 25 | 26 | if __name__ == "__main__": 27 | run() 28 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup file for rest_framework_sav.""" 2 | import os 3 | import sys 4 | 5 | from setuptools import setup, find_packages 6 | 7 | import rest_framework_sav 8 | 9 | 10 | version = rest_framework_sav.__version__ 11 | 12 | if sys.argv[-1] == 'publish': 13 | if os.system("pip freeze | grep wheel"): 14 | print("wheel not installed.\nUse `pip install wheel`.\nExiting.") 15 | sys.exit() 16 | if os.system("pip freeze | grep twine"): 17 | print("twine not installed.\nUse `pip install twine`.\nExiting.") 18 | sys.exit() 19 | os.system("python setup.py sdist bdist_wheel") 20 | os.system("twine upload dist/*") 21 | print("You probably want to also tag the version now:") 22 | print(" git tag -a %s -m 'version %s'" % (version, version)) 23 | print(" git push --tags") 24 | sys.exit() 25 | 26 | setup( 27 | name='djangorestframework-sav', 28 | version=version, 29 | description='Session Authentication View for Django REST Framework', 30 | url='https://github.com/JamesRitchie/django-rest-framework-sav', 31 | author='James Ritchie', 32 | author_email='james.a.ritchie@gmail.com', 33 | license='BSD', 34 | packages=find_packages(exclude=['tests*']), 35 | install_requires=[ 36 | 'djangorestframework>=3.0.5' 37 | ], 38 | test_suite='runtests.run', 39 | tests_require=['Django'], 40 | zip_safe=False, 41 | classifiers=[ 42 | 'Development Status :: 5 - Production/Stable', 43 | 'Environment :: Web Environment', 44 | 'Framework :: Django', 45 | 'Intended Audience :: Developers', 46 | 'License :: OSI Approved :: BSD License', 47 | 'Operating System :: OS Independent', 48 | 'Programming Language :: Python :: 2', 49 | 'Programming Language :: Python :: 2.6', 50 | 'Programming Language :: Python :: 2.7', 51 | 'Programming Language :: Python :: 3', 52 | 'Programming Language :: Python :: 3.2', 53 | 'Programming Language :: Python :: 3.3', 54 | 'Programming Language :: Python :: 3.4', 55 | 'Topic :: Internet :: WWW/HTTP', 56 | ] 57 | ) 58 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JamesRitchie/django-rest-framework-sav/a968129be88a1981d9904c3679e5fdd9490e890d/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | """Blank file required to run tests on Django 1.4.""" 2 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'fake-key' 2 | 3 | MIDDLEWARE_CLASSES = [ 4 | 'django.middleware.common.CommonMiddleware', 5 | 'django.contrib.sessions.middleware.SessionMiddleware', 6 | 'django.middleware.csrf.CsrfViewMiddleware', 7 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 8 | ] 9 | 10 | INSTALLED_APPS = [ 11 | 'django.contrib.auth', 12 | 'django.contrib.contenttypes', 13 | 'django.contrib.sessions', 14 | 'django.contrib.sites', 15 | 'django.contrib.messages', 16 | 'django.contrib.staticfiles', 17 | 18 | 'rest_framework', 19 | 'rest_framework_sav', 20 | "tests", 21 | ] 22 | 23 | DATABASES = { 24 | 'default': { 25 | 'ENGINE': 'django.db.backends.sqlite3', 26 | 'NAME': ':memory:' 27 | } 28 | } 29 | 30 | ROOT_URLCONF = 'tests.urls' 31 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | """Tests for Django Rest Framework Session Authentication package.""" 2 | from django.contrib.auth.models import User 3 | 4 | from rest_framework import status 5 | from rest_framework.test import APITestCase 6 | 7 | 8 | class SessionAuthViewTestCase(APITestCase): 9 | 10 | """Tests for functions on the SessionAuthView.""" 11 | 12 | def setUp(self): 13 | """Create a test user.""" 14 | self.username = 'test' 15 | self.email = 'test@test.com' 16 | self.password = 'test' 17 | self.user = User.objects.create_user( 18 | username=self.username, 19 | email=self.email, 20 | password=self.password 21 | ) 22 | 23 | def check_client_logged_out(self): 24 | """Utility function to check session auth logout worked.""" 25 | self.assertEqual(len(self.client.session.items()), 0) 26 | 27 | def check_client_logged_in(self): 28 | """Utility function to check session auth login worked.""" 29 | self.assertEqual( 30 | self.client.session['_auth_user_backend'], 31 | 'django.contrib.auth.backends.ModelBackend' 32 | ) 33 | self.assertEqual( 34 | int(self.client.session['_auth_user_id']), 35 | self.user.id 36 | ) 37 | 38 | # Apparently _auth_user_hash is in 1.4 and 1.7, but not 1.6? 39 | # self.assertEqual( 40 | # self.client.session['_auth_user_hash'], 41 | # self.user.get_session_auth_hash() 42 | # ) 43 | 44 | def test_session_login_json(self): 45 | """Test for correct login by posting credentials as JSON.""" 46 | self.check_client_logged_out() 47 | 48 | response = self.client.post( 49 | '/auth-session/', 50 | { 51 | 'username': self.username, 52 | 'password': self.password 53 | }, 54 | format='json' 55 | ) 56 | 57 | self.assertEqual(response.status_code, status.HTTP_200_OK) 58 | self.assertEqual(response.data, 59 | { 60 | 'detail': 'Session login successful.' 61 | } 62 | ) 63 | self.check_client_logged_in() 64 | 65 | def test_session_login_form(self): 66 | """Test for correct login by posting credentials as a form.""" 67 | self.check_client_logged_out() 68 | 69 | response = self.client.post( 70 | '/auth-session/', 71 | { 72 | 'username': self.username, 73 | 'password': self.password 74 | } 75 | ) 76 | 77 | self.assertEqual(response.status_code, status.HTTP_200_OK) 78 | self.assertEqual(response.data, 79 | { 80 | 'detail': 'Session login successful.' 81 | } 82 | ) 83 | self.check_client_logged_in() 84 | 85 | def test_session_login_no_credentials_json(self): 86 | """Test login fails when no credentials posted by JSON.""" 87 | self.check_client_logged_out() 88 | 89 | response = self.client.post( 90 | '/auth-session/', 91 | None, 92 | format='json' 93 | ) 94 | 95 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 96 | self.assertEqual(response.data, 97 | { 98 | 'password': ['This field is required.'], 99 | 'username': ['This field is required.'] 100 | } 101 | ) 102 | self.check_client_logged_out() 103 | 104 | def test_session_login_no_credentials_form(self): 105 | """Test login fails when no credentials posted by form.""" 106 | self.check_client_logged_out() 107 | 108 | response = self.client.post( 109 | '/auth-session/', 110 | None 111 | ) 112 | 113 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 114 | self.assertEqual(response.data, 115 | { 116 | 'password': ['This field is required.'], 117 | 'username': ['This field is required.'] 118 | } 119 | ) 120 | self.check_client_logged_out() 121 | 122 | def test_session_login_bad_credentials_json(self): 123 | """Test login fails when bad credentials posted by JSON.""" 124 | self.check_client_logged_out() 125 | 126 | response = self.client.post( 127 | '/auth-session/', 128 | { 129 | 'username': 'wrong', 130 | 'password': 'wrong' 131 | }, 132 | format='json' 133 | ) 134 | 135 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 136 | self.assertEqual(response.data, 137 | { 138 | 'non_field_errors': [ 139 | 'Unable to log in with provided credentials.' 140 | ] 141 | } 142 | ) 143 | self.check_client_logged_out() 144 | 145 | def test_session_login_bad_credentials_form(self): 146 | """Test login fails when bad credentials posted by form.""" 147 | self.check_client_logged_out() 148 | 149 | response = self.client.post( 150 | '/auth-session/', 151 | { 152 | 'username': 'wrong', 153 | 'password': 'wrong' 154 | } 155 | ) 156 | 157 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 158 | self.assertEqual(response.data, 159 | { 160 | 'non_field_errors': [ 161 | 'Unable to log in with provided credentials.' 162 | ] 163 | } 164 | ) 165 | self.check_client_logged_out() 166 | 167 | def test_session_login_inactive_user(self): 168 | """Check that an inactive user can't login.""" 169 | self.check_client_logged_out() 170 | self.user.is_active = False 171 | self.user.save() 172 | 173 | response = self.client.post( 174 | '/auth-session/', 175 | { 176 | 'username': self.username, 177 | 'password': self.password 178 | } 179 | ) 180 | 181 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 182 | self.assertEqual(response.data, 183 | { 184 | 'non_field_errors': [ 185 | 'User account is disabled.' 186 | ] 187 | } 188 | ) 189 | self.check_client_logged_out() 190 | 191 | def test_session_logout(self): 192 | """Test logout occurs when DELETE request sent.""" 193 | self.client.post( 194 | '/auth-session/', 195 | { 196 | 'username': self.username, 197 | 'password': self.password 198 | } 199 | ) 200 | self.check_client_logged_in() 201 | 202 | response = self.client.delete('/auth-session/') 203 | 204 | self.assertEqual(response.status_code, status.HTTP_200_OK) 205 | self.assertEqual(response.data, 206 | { 207 | 'detail': 'Session logout successful.' 208 | } 209 | ) 210 | self.check_client_logged_out() 211 | 212 | def test_session_logout_no_credentials(self): 213 | """Check logout still works even without login.""" 214 | self.check_client_logged_out() 215 | 216 | response = self.client.delete('/auth-session/') 217 | 218 | self.assertEqual(response.status_code, status.HTTP_200_OK) 219 | self.assertEqual(response.data, 220 | { 221 | 'detail': 'Session logout successful.' 222 | } 223 | ) 224 | self.check_client_logged_out() 225 | 226 | def test_get(self): 227 | """Check GET request denied.""" 228 | response = self.client.get('/auth-session/') 229 | self.assertEqual( 230 | response.status_code, 231 | status.HTTP_405_METHOD_NOT_ALLOWED 232 | ) 233 | 234 | def test_put(self): 235 | """Check PUT request denied.""" 236 | response = self.client.put( 237 | '/auth-session/', 238 | { 239 | 'username': self.username, 240 | 'password': self.password 241 | } 242 | ) 243 | self.assertEqual( 244 | response.status_code, 245 | status.HTTP_405_METHOD_NOT_ALLOWED 246 | ) 247 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns 2 | 3 | from tests.views import MockView 4 | 5 | from rest_framework_sav.views import session_auth_view 6 | 7 | 8 | urlpatterns = patterns( 9 | '', 10 | (r'^view/$', MockView.as_view()), 11 | (r'^auth-session/$', session_auth_view) 12 | ) 13 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | from rest_framework.authentication import SessionAuthentication 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.views import APIView 6 | 7 | 8 | class MockView(APIView): 9 | 10 | authentication_classes = (SessionAuthentication,) 11 | permission_classes = (IsAuthenticated,) 12 | 13 | def get(self, request): 14 | return HttpResponse({'a': 1, 'b': 2, 'c': 3}) 15 | 16 | def post(self, request): 17 | return HttpResponse({'a': 1, 'b': 2, 'c': 3}) 18 | 19 | def put(self, request): 20 | return HttpResponse({'a': 1, 'b': 2, 'c': 3}) 21 | --------------------------------------------------------------------------------