├── .coveragerc ├── .editorconfig ├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── setup.py ├── simple_sso ├── __init__.py ├── exceptions.py ├── models.py ├── sso_client │ ├── __init__.py │ └── client.py ├── sso_server │ ├── __init__.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_consumer_name_max_length.py │ │ ├── 0003_token_redirect_to_max_length.py │ │ └── __init__.py │ ├── models.py │ └── server.py └── utils.py ├── tests ├── __init__.py ├── requirements.txt ├── settings.py ├── test_core.py ├── test_migrations.py ├── urls.py └── utils │ ├── __init__.py │ └── context_managers.py ├── tox.ini └── travis.yml /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = simple_sso 4 | omit = 5 | migrations/* 6 | tests/* 7 | 8 | [report] 9 | exclude_lines = 10 | pragma: no cover 11 | def __repr__ 12 | if self.debug: 13 | if settings.DEBUG 14 | raise AssertionError 15 | raise NotImplementedError 16 | if 0: 17 | if __name__ == .__main__.: 18 | ignore_errors = True 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | max_line_length = 80 13 | 14 | [*.py] 15 | max_line_length = 120 16 | quote_type = single 17 | 18 | [*.{scss,js,html}] 19 | max_line_length = 120 20 | indent_style = space 21 | quote_type = double 22 | 23 | [*.js] 24 | max_line_length = 120 25 | quote_type = single 26 | 27 | [*.rst] 28 | max_line_length = 80 29 | 30 | [*.yml] 31 | indent_size = 2 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *$py.class 3 | *.egg-info 4 | *.log 5 | *.pot 6 | .DS_Store 7 | .coverage/ 8 | .eggs/ 9 | .idea/ 10 | .project/ 11 | .pydevproject/ 12 | .vscode/ 13 | .settings/ 14 | .tox/ 15 | __pycache__/ 16 | build/ 17 | dist/ 18 | env/ 19 | 20 | local.sqlite 21 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | 1.3.0 (2025-05-05) 6 | ================== 7 | 8 | * Remove the abandoned dependency `webservices`, causing issues in the newer python versions because of the use of reserved names. 9 | 10 | 11 | 1.2.0 (2022-12-14) 12 | ================== 13 | 14 | * Increased the max length of the Token.Token.redirect_to field to 1023 15 | 16 | 17 | 1.1.0 (2021-08-16) 18 | ================== 19 | 20 | * Added support to update user-data on login (#61) 21 | 22 | 23 | 1.0.0 (2020-09-03) 24 | ================== 25 | 26 | * Added changelog 27 | * Added test framework 28 | * Added support for Django 3.1 29 | * Dropped support for Python 2.7 and Python 3.4 30 | * Dropped support for Django < 2.2 31 | * Aligned files with other addons 32 | * Pinned itsdangerous<1.0.0 as the timestamp calculations changed 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011, Jonas Obrist 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 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of Jonas Obrist nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL JONAS OBRIST BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-exclude * *.py[co] 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | django-simple-sso 3 | ================= 4 | 5 | |pypi| |build| |coverage| 6 | 7 | 8 | Documentation 9 | ============= 10 | 11 | See ``REQUIREMENTS`` in the `setup.py `_ 12 | file for additional dependencies: 13 | 14 | |python| |django| 15 | 16 | 17 | Django Simple SSO Specification (DRAFT) 18 | ======================================= 19 | 20 | Terminology 21 | *********** 22 | 23 | Server 24 | ------ 25 | 26 | The server is a Django website that holds all the user information and 27 | authenticates users. 28 | 29 | Client 30 | ------ 31 | 32 | The client is a Django website that provides login via SSO using the **Server**. 33 | It does not hold any user information. 34 | 35 | Key 36 | --- 37 | 38 | A unique key identifying a **Client**. This key can be made public. 39 | 40 | Secret 41 | ------ 42 | 43 | A secret key shared between the **Server** and a single **Client**. This secret 44 | should never be shared with anyone other than the **Server** and **Client** and 45 | must not be transferred unencrypted. 46 | 47 | Workflow 48 | ******** 49 | 50 | * User wants to log into a **Client** by clicking a "Login" button. The 51 | initially requested URL can be passed using the ``next`` GET parameter. 52 | * The **Client**'s Python code does a HTTP request to the **Server** to request a 53 | authentication token, this is called the **Request Token Request**. 54 | * The **Server** returns a **Request Token**. 55 | * The **Client** redirects the User to a view on the **Server** using the 56 | **Request Token**, this is the **Authorization Request**. 57 | * If the user is not logged in the the **Server**, they are prompted to log in. 58 | * The user is redirected to the **Client** including the **Request Token** and a 59 | **Auth Token**, this is the ``Authentication Request``. 60 | * The **Client**'s Python code does a HTTP request to the **Server** to verify the 61 | **Auth Token**, this is called the **Auth Token Verification Request**. 62 | * If the **Auth Token** is valid, the **Server** returns a serialized Django User 63 | object. 64 | * The **Client** logs the user in using the Django User received from the **Server**. 65 | 66 | Requests 67 | ******** 68 | 69 | General 70 | ------- 71 | 72 | All requests have a ``signature`` and ``key`` parameter, see **Security**. 73 | 74 | Request Token Request 75 | --------------------- 76 | 77 | * Client: Python 78 | * Target: **Server** 79 | * Method: GET 80 | * Extra Parameters: None 81 | * Responses: 82 | 83 | * ``200``: Everything went fine, the body of the response is a url encoded 84 | query string containing with the ``request_token`` key holding the 85 | **Request Token** as well as the ``signature``. 86 | * ``400``: Bad request (missing GET parameters) 87 | * ``403``: Forbidden (invalid signature) 88 | 89 | 90 | Authorization Request 91 | --------------------- 92 | 93 | * Client: Browser (User) 94 | * Target: **Server** 95 | * Method: GET 96 | * Extra Parameters: 97 | 98 | * ``request_token`` 99 | 100 | * Responses: 101 | 102 | * ``200``: Everything okay, prompt user to log in or continue. 103 | * ``400``: Bad request (missing GET parameter). 104 | * ``403``: Forbidden (invalid **Request Token**). 105 | 106 | 107 | Authentication Request 108 | ---------------------- 109 | 110 | * Client: Browser (User) 111 | * Target: **Client** 112 | * Method: GET 113 | * Extra Parameters: 114 | 115 | * ``request_token``: The **Request Token** returned by the 116 | **Request Token Request**. 117 | * ``auth_token``: The **Auth Token** generated by the **Authorization Request**. 118 | 119 | * Responses: 120 | 121 | * ``200``: Everything went fine, the user is now logged in. 122 | * ``400``: Bad request (missing GET parameters). 123 | * ``403``: Forbidden (invalid **Request Token**). 124 | 125 | 126 | Auth Token Verification Request 127 | ------------------------------- 128 | 129 | * Client: Python 130 | * Target: **Server** 131 | * Method: GET 132 | * Extra Parameters: 133 | 134 | * ``auth_token``: The **Auth Token** obtained by the **Authentication Request**. 135 | 136 | * Responses: 137 | 138 | * ``200``: Everything went fine, the body of the response is a url encoded 139 | query string containing the ``user`` key which is the JSON serialized 140 | representation of the Django user to create as well as the ``signature``. 141 | 142 | Security 143 | ******** 144 | 145 | Every request is signed using HMAC-SHA256. The signature is in the ``signature`` 146 | parameter. The signature message is the urlencoded, alphabetically ordered 147 | query string. The signature key is the **Secret** of the **Client**. To verify 148 | the signature the ``key`` paramater holding the **key** of the **Client** is 149 | also sent with every request from the **Client** to the **Server**. 150 | 151 | Example 152 | ------- 153 | 154 | GET Request with the GET parameters ``key=bundle123`` and the private key 155 | ``secret key``: ``fbf6396d0fc40d563e2be3c861f7eb5a1b821b76c2ac943d40a7a63b288619a9`` 156 | 157 | The User object 158 | *************** 159 | 160 | The User object returned by a successful **Auth Token Verification Request** 161 | does not contain all the information about the Django User, in particular, it 162 | does not contain the password. 163 | 164 | The user object contains must contain at least the following data: 165 | 166 | * ``username``: The unique username of this user. 167 | * ``email``: The email of this user. 168 | * ``first_name``: The first name of this user, this field is required, but may 169 | be empty. 170 | * ``last_name``: The last name of this user, this field is required, but may 171 | be empty. 172 | * ``is_staff``: Can this user access the Django admin on the **Client**? 173 | * ``is_superuser``: Does this user have superuser access to the **Client**? 174 | * ``is_active``: Is the user active? 175 | 176 | Implementation 177 | ************** 178 | 179 | On the server 180 | ------------- 181 | 182 | * Add ``simple_sso.sso_server`` to ``INSTALLED_APPS``. 183 | * Create an instance (potentially of a subclass) of 184 | ``simple_sso.sso_server.server.Server`` and include the return value of the 185 | ``get_urls`` method on that instance into your url patterns. 186 | 187 | 188 | On the client 189 | ------------- 190 | 191 | * Create a new instance of ``simple_sso.sso_server.models.Consumer`` on the 192 | **Server**. 193 | * Add the ``SIMPLE_SSO_SECRET`` and ``SIMPLE_SSO_KEY`` settings as provided by 194 | the **Server**'s ``simple_sso.sso_server.models.Client`` model. 195 | * Add the ``SIMPLE_SSO_SERVER`` setting which is the absolute URL pointing to 196 | the root where the ``simple_sso.sso_server.urls`` where include on the 197 | **Server**. 198 | * Add the ``simple_sso.sso_client.urls`` patterns somewhere on the client. 199 | 200 | 201 | Running Tests 202 | ************* 203 | 204 | You can run tests by executing:: 205 | 206 | virtualenv env 207 | source env/bin/activate 208 | pip install -r tests/requirements.txt 209 | python setup.py test 210 | 211 | 212 | .. |pypi| image:: https://badge.fury.io/py/django-simple.sso.svg 213 | :target: http://badge.fury.io/py/django-simple.sso 214 | .. |build| image:: https://travis-ci.org/divio/django-simple.sso.svg?branch=master 215 | :target: https://travis-ci.org/divio/django-simple.sso 216 | .. |coverage| image:: https://codecov.io/gh/divio/django-simple.sso/branch/master/graph/badge.svg 217 | :target: https://codecov.io/gh/divio/django-simple.sso 218 | 219 | .. |python| image:: https://img.shields.io/badge/python-3.5+-blue.svg 220 | :target: https://pypi.org/project/django-simple.sso/ 221 | .. |django| image:: https://img.shields.io/badge/django-2.2,%203.0,%203.1-blue.svg 222 | :target: https://www.djangoproject.com/ 223 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import find_packages, setup 3 | 4 | from simple_sso import __version__ 5 | 6 | 7 | REQUIREMENTS = [ 8 | 'Django>=2.2', 9 | 'itsdangerous<1.0.0', 10 | 'requests', 11 | ] 12 | 13 | 14 | CLASSIFIERS = [ 15 | 'Development Status :: 5 - Production/Stable', 16 | 'Environment :: Web Environment', 17 | 'Intended Audience :: Developers', 18 | 'License :: OSI Approved :: BSD License', 19 | 'Operating System :: OS Independent', 20 | 'Programming Language :: Python', 21 | 'Programming Language :: Python :: 3', 22 | 'Programming Language :: Python :: 3.5', 23 | 'Programming Language :: Python :: 3.6', 24 | 'Programming Language :: Python :: 3.7', 25 | 'Programming Language :: Python :: 3.8', 26 | 'Framework :: Django', 27 | 'Framework :: Django :: 2.2', 28 | 'Framework :: Django :: 3.0', 29 | 'Framework :: Django :: 3.1', 30 | 'Topic :: Internet :: WWW/HTTP', 31 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 32 | 'Topic :: Software Development', 33 | 'Topic :: Software Development :: Libraries', 34 | ] 35 | 36 | 37 | setup( 38 | name='django-simple-sso', 39 | version=__version__, 40 | author='Divio AG', 41 | author_email='info@divio.com', 42 | url='http://github.com/aldryn/django-simple-sso', 43 | license='BSD-3-Clause', 44 | description='Simple SSO for Django', 45 | long_description=open('README.rst').read(), 46 | packages=find_packages(), 47 | include_package_data=True, 48 | zip_safe=False, 49 | install_requires=REQUIREMENTS, 50 | classifiers=CLASSIFIERS, 51 | test_suite='tests.settings.run', 52 | ) 53 | -------------------------------------------------------------------------------- /simple_sso/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.3.0' 2 | -------------------------------------------------------------------------------- /simple_sso/exceptions.py: -------------------------------------------------------------------------------- 1 | class WebserviceError(Exception): 2 | pass 3 | 4 | 5 | class BadRequest(WebserviceError): 6 | pass 7 | -------------------------------------------------------------------------------- /simple_sso/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is only here so I can run tests 3 | """ 4 | -------------------------------------------------------------------------------- /simple_sso/sso_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divio/django-simple-sso/4037ed52fd7d11c0de105abc614bb4ecd042ab19/simple_sso/sso_client/__init__.py -------------------------------------------------------------------------------- /simple_sso/sso_client/client.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from urllib.parse import urlparse, urlunparse, urljoin, urlencode 3 | 4 | from django.urls import re_path 5 | from django.contrib.auth import login 6 | from django.contrib.auth.backends import ModelBackend 7 | from django.contrib.auth.models import User 8 | from django.http import HttpResponseRedirect 9 | from django.urls import NoReverseMatch, reverse 10 | from django.views.generic import View 11 | from itsdangerous import URLSafeTimedSerializer 12 | 13 | from simple_sso.utils import SyncConsumer 14 | 15 | 16 | class LoginView(View): 17 | client = None 18 | 19 | def get(self, request): 20 | next_ = self.get_next() 21 | scheme = 'https' if request.is_secure() else 'http' 22 | query = urlencode([('next', next_)]) 23 | netloc = request.get_host() 24 | path = reverse('simple-sso-authenticate') 25 | redirect_to = urlunparse((scheme, netloc, path, '', query, '')) 26 | request_token = self.client.get_request_token(redirect_to) 27 | host = urljoin(self.client.server_url, 'authorize/') 28 | url = '%s?%s' % (host, urlencode([('token', request_token)])) 29 | return HttpResponseRedirect(url) 30 | 31 | def get_next(self): 32 | """ 33 | Given a request, returns the URL where a user should be redirected to 34 | after login. Defaults to '/' 35 | """ 36 | next_ = self.request.GET.get('next', None) 37 | if not next_: 38 | return '/' 39 | netloc = urlparse(next_)[1] 40 | # Heavier security check -- don't allow redirection to a different 41 | # host. 42 | # Taken from django.contrib.auth.views.login 43 | if netloc and netloc != self.request.get_host(): 44 | return '/' 45 | return next_ 46 | 47 | 48 | class AuthenticateView(LoginView): 49 | client = None 50 | 51 | def get(self, request): 52 | raw_access_token = request.GET['access_token'] 53 | access_token = URLSafeTimedSerializer(self.client.private_key).loads(raw_access_token) 54 | user = self.client.get_user(access_token) 55 | user.backend = self.client.backend 56 | login(request, user) 57 | next_ = self.get_next() 58 | return HttpResponseRedirect(next_) 59 | 60 | 61 | class Client: 62 | login_view = LoginView 63 | authenticate_view = AuthenticateView 64 | backend = "%s.%s" % (ModelBackend.__module__, ModelBackend.__name__) 65 | user_extra_data = None 66 | 67 | def __init__(self, server_url, public_key, private_key, 68 | user_extra_data=None): 69 | self.server_url = server_url 70 | self.public_key = public_key 71 | self.private_key = private_key 72 | self.consumer = SyncConsumer(self.server_url, self.public_key, self.private_key) 73 | if user_extra_data: 74 | self.user_extra_data = user_extra_data 75 | 76 | @classmethod 77 | def from_dsn(cls, dsn): 78 | parse_result = urlparse(dsn) 79 | public_key = parse_result.username 80 | private_key = parse_result.password 81 | netloc = parse_result.hostname 82 | if parse_result.port: 83 | netloc += ':%s' % parse_result.port 84 | server_url = urlunparse((parse_result.scheme, netloc, parse_result.path, 85 | parse_result.params, parse_result.query, parse_result.fragment)) 86 | return cls(server_url, public_key, private_key) 87 | 88 | def get_request_token(self, redirect_to): 89 | try: 90 | url = reverse('simple-sso-request-token') 91 | except NoReverseMatch: 92 | # thisisfine 93 | url = '/request-token/' 94 | return self.consumer.consume(url, {'redirect_to': redirect_to})['request_token'] 95 | 96 | def get_user(self, access_token): 97 | data = {'access_token': access_token} 98 | if self.user_extra_data: 99 | data['extra_data'] = self.user_extra_data 100 | 101 | try: 102 | url = reverse('simple-sso-verify') 103 | except NoReverseMatch: 104 | # thisisfine 105 | url = '/verify/' 106 | user_data = self.consumer.consume(url, data) 107 | user = self.build_user(user_data) 108 | return user 109 | 110 | def build_user(self, user_data): 111 | try: 112 | user = User.objects.get(username=user_data['username']) 113 | # Update user data, excluding username changes 114 | # Work on copied _tmp dict to keep an untouched user_data 115 | user_data_tmp = copy(user_data) 116 | del user_data_tmp['username'] 117 | for _attr, _val in user_data_tmp.items(): 118 | setattr(user, _attr, _val) 119 | except User.DoesNotExist: 120 | user = User(**user_data) 121 | user.set_unusable_password() 122 | user.save() 123 | return user 124 | 125 | def get_urls(self): 126 | return [ 127 | re_path(r'^$', self.login_view.as_view(client=self), name='simple-sso-login'), 128 | re_path(r'^authenticate/$', self.authenticate_view.as_view(client=self), name='simple-sso-authenticate'), 129 | ] 130 | -------------------------------------------------------------------------------- /simple_sso/sso_server/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'simple_sso.sso_server.apps.SimpleSSOServer' 2 | -------------------------------------------------------------------------------- /simple_sso/sso_server/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SimpleSSOServer(AppConfig): 5 | name = 'simple_sso.sso_server' 6 | -------------------------------------------------------------------------------- /simple_sso/sso_server/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | from django.utils import timezone 3 | from django.conf import settings 4 | import simple_sso.sso_server.models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Consumer', 16 | fields=[ 17 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 18 | ('name', models.CharField(unique=True, max_length=100)), 19 | ('private_key', models.CharField(default=simple_sso.sso_server.models.ConsumerSecretKeyGenerator('private_key'), unique=True, max_length=64)), 20 | ('public_key', models.CharField(default=simple_sso.sso_server.models.ConsumerSecretKeyGenerator('public_key'), unique=True, max_length=64)), 21 | ], 22 | ), 23 | migrations.CreateModel( 24 | name='Token', 25 | fields=[ 26 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 27 | ('request_token', models.CharField(default=simple_sso.sso_server.models.TokenSecretKeyGenerator('request_token'), unique=True, max_length=64)), 28 | ('access_token', models.CharField(default=simple_sso.sso_server.models.TokenSecretKeyGenerator('access_token'), unique=True, max_length=64)), 29 | ('timestamp', models.DateTimeField(default=timezone.now)), 30 | ('redirect_to', models.CharField(max_length=255)), 31 | ('consumer', models.ForeignKey(related_name='tokens', to='sso_server.Consumer', on_delete=models.CASCADE)), 32 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), 33 | ], 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /simple_sso/sso_server/migrations/0002_consumer_name_max_length.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('sso_server', '0001_initial'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name='consumer', 13 | name='name', 14 | field=models.CharField(unique=True, max_length=255), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /simple_sso/sso_server/migrations/0003_token_redirect_to_max_length.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('sso_server', '0002_consumer_name_max_length'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name='token', 13 | name='redirect_to', 14 | field=models.CharField(max_length=1023), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /simple_sso/sso_server/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divio/django-simple-sso/4037ed52fd7d11c0de105abc614bb4ecd042ab19/simple_sso/sso_server/migrations/__init__.py -------------------------------------------------------------------------------- /simple_sso/sso_server/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.utils import timezone 4 | from django.utils.deconstruct import deconstructible 5 | 6 | from ..utils import gen_secret_key 7 | 8 | 9 | @deconstructible 10 | class SecretKeyGenerator: 11 | """ 12 | Helper to give default values to Client.secret and Client.key 13 | """ 14 | 15 | def __init__(self, field): 16 | self.field = field 17 | 18 | def __call__(self): 19 | key = gen_secret_key(64) 20 | while self.get_model().objects.filter(**{self.field: key}).exists(): 21 | key = gen_secret_key(64) 22 | return key 23 | 24 | 25 | class ConsumerSecretKeyGenerator(SecretKeyGenerator): 26 | def get_model(self): 27 | return Consumer 28 | 29 | 30 | class TokenSecretKeyGenerator(SecretKeyGenerator): 31 | def get_model(self): 32 | return Token 33 | 34 | 35 | class Consumer(models.Model): 36 | name = models.CharField(max_length=255, unique=True) 37 | private_key = models.CharField( 38 | max_length=64, unique=True, 39 | default=ConsumerSecretKeyGenerator('private_key') 40 | ) 41 | public_key = models.CharField( 42 | max_length=64, unique=True, 43 | default=ConsumerSecretKeyGenerator('public_key') 44 | ) 45 | 46 | def __unicode__(self): 47 | return self.name 48 | 49 | def rotate_keys(self): 50 | self.secret = ConsumerSecretKeyGenerator('private_key')() 51 | self.key = ConsumerSecretKeyGenerator('public_key')() 52 | self.save() 53 | 54 | 55 | class Token(models.Model): 56 | consumer = models.ForeignKey( 57 | Consumer, 58 | related_name='tokens', 59 | on_delete=models.CASCADE, 60 | ) 61 | request_token = models.CharField( 62 | unique=True, max_length=64, 63 | default=TokenSecretKeyGenerator('request_token') 64 | ) 65 | access_token = models.CharField( 66 | unique=True, max_length=64, 67 | default=TokenSecretKeyGenerator('access_token') 68 | ) 69 | timestamp = models.DateTimeField(default=timezone.now) 70 | redirect_to = models.CharField(max_length=1023) 71 | user = models.ForeignKey( 72 | getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), 73 | null=True, 74 | on_delete=models.CASCADE, 75 | ) 76 | 77 | def refresh(self): 78 | self.timestamp = timezone.now() 79 | self.save() 80 | -------------------------------------------------------------------------------- /simple_sso/sso_server/server.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from urllib.parse import urlparse, urlencode, urlunparse 3 | 4 | from django.contrib import admin 5 | from django.contrib.admin.options import ModelAdmin 6 | from django.http import (HttpResponseForbidden, HttpResponseBadRequest, HttpResponseRedirect, QueryDict) 7 | from django.urls import re_path 8 | from django.urls import reverse 9 | from django.utils import timezone 10 | from django.views.generic.base import View 11 | from itsdangerous import URLSafeTimedSerializer 12 | 13 | from simple_sso.sso_server.models import Token, Consumer 14 | from simple_sso.utils import BaseProvider, provider_wrapper 15 | 16 | 17 | class Provider(BaseProvider): 18 | max_age = 5 19 | 20 | def __init__(self, server): 21 | self.server = server 22 | 23 | def get_private_key(self, public_key): 24 | try: 25 | self.consumer = Consumer.objects.get(public_key=public_key) 26 | except Consumer.DoesNotExist: 27 | return None 28 | return self.consumer.private_key 29 | 30 | 31 | class RequestTokenProvider(Provider): 32 | def provide(self, data): 33 | redirect_to = data['redirect_to'] 34 | token = Token.objects.create(consumer=self.consumer, redirect_to=redirect_to) 35 | return {'request_token': token.request_token} 36 | 37 | 38 | class AuthorizeView(View): 39 | """ 40 | The client get's redirected to this view with the `request_token` obtained 41 | by the Request Token Request by the client application beforehand. 42 | 43 | This view checks if the user is logged in on the server application and if 44 | that user has the necessary rights. 45 | 46 | If the user is not logged in, the user is prompted to log in. 47 | """ 48 | server = None 49 | 50 | def get(self, request): 51 | request_token = request.GET.get('token', None) 52 | if not request_token: 53 | return self.missing_token_argument() 54 | try: 55 | self.token = Token.objects.select_related('consumer').get(request_token=request_token) 56 | except Token.DoesNotExist: 57 | return self.token_not_found() 58 | if not self.check_token_timeout(): 59 | return self.token_timeout() 60 | self.token.refresh() 61 | if request.user.is_authenticated: 62 | return self.handle_authenticated_user() 63 | else: 64 | return self.handle_unauthenticated_user() 65 | 66 | def missing_token_argument(self): 67 | return HttpResponseBadRequest('Token missing') 68 | 69 | def token_not_found(self): 70 | return HttpResponseForbidden('Token not found') 71 | 72 | def token_timeout(self): 73 | return HttpResponseForbidden('Token timed out') 74 | 75 | def check_token_timeout(self): 76 | delta = timezone.now() - self.token.timestamp 77 | if delta > self.server.token_timeout: 78 | self.token.delete() 79 | return False 80 | else: 81 | return True 82 | 83 | def handle_authenticated_user(self): 84 | if self.server.has_access(self.request.user, self.token.consumer): 85 | return self.success() 86 | else: 87 | return self.access_denied() 88 | 89 | def handle_unauthenticated_user(self): 90 | next_ = '%s?%s' % (self.request.path, urlencode([('token', self.token.request_token)])) 91 | url = '%s?%s' % (reverse(self.server.auth_view_name), urlencode([('next', next_)])) 92 | return HttpResponseRedirect(url) 93 | 94 | def access_denied(self): 95 | return HttpResponseForbidden("Access denied") 96 | 97 | def success(self): 98 | self.token.user = self.request.user 99 | self.token.save() 100 | serializer = URLSafeTimedSerializer(self.token.consumer.private_key) 101 | parse_result = urlparse(self.token.redirect_to) 102 | query_dict = QueryDict(parse_result.query, mutable=True) 103 | query_dict['access_token'] = serializer.dumps(self.token.access_token) 104 | url = urlunparse((parse_result.scheme, parse_result.netloc, parse_result.path, '', query_dict.urlencode(), '')) 105 | return HttpResponseRedirect(url) 106 | 107 | 108 | class VerificationProvider(Provider, AuthorizeView): 109 | def provide(self, data): 110 | token = data['access_token'] 111 | try: 112 | self.token = Token.objects.select_related('user').get(access_token=token, consumer=self.consumer) 113 | except Token.DoesNotExist: 114 | return self.token_not_found() 115 | if not self.check_token_timeout(): 116 | return self.token_timeout() 117 | if not self.token.user: 118 | return self.token_not_bound() 119 | extra_data = data.get('extra_data', None) 120 | return self.server.get_user_data( 121 | self.token.user, self.consumer, extra_data=extra_data) 122 | 123 | def token_not_bound(self): 124 | return HttpResponseForbidden("Invalid token") 125 | 126 | 127 | class ConsumerAdmin(ModelAdmin): 128 | readonly_fields = ['public_key', 'private_key'] 129 | 130 | 131 | class Server: 132 | request_token_provider = RequestTokenProvider 133 | authorize_view = AuthorizeView 134 | verification_provider = VerificationProvider 135 | token_timeout = datetime.timedelta(minutes=5) 136 | client_admin = ConsumerAdmin 137 | auth_view_name = 'login' 138 | 139 | def __init__(self, **kwargs): 140 | for key, value in kwargs.items(): 141 | setattr(self, key, value) 142 | self.register_admin() 143 | 144 | def register_admin(self): 145 | admin.site.register(Consumer, self.client_admin) 146 | 147 | def has_access(self, user, consumer): 148 | return True 149 | 150 | def get_user_extra_data(self, user, consumer, extra_data): 151 | raise NotImplementedError() 152 | 153 | def get_user_data(self, user, consumer, extra_data=None): 154 | user_data = { 155 | 'username': user.username, 156 | 'email': user.email, 157 | 'first_name': user.first_name, 158 | 'last_name': user.last_name, 159 | 'is_staff': False, 160 | 'is_superuser': False, 161 | 'is_active': user.is_active, 162 | } 163 | if extra_data: 164 | user_data['extra_data'] = self.get_user_extra_data( 165 | user, consumer, extra_data) 166 | return user_data 167 | 168 | def get_urls(self): 169 | return [ 170 | re_path(r'^request-token/$', provider_wrapper(self.request_token_provider(server=self)), 171 | name='simple-sso-request-token'), 172 | re_path(r'^authorize/$', self.authorize_view.as_view(server=self), name='simple-sso-authorize'), 173 | re_path(r'^verify/$', provider_wrapper( 174 | self.verification_provider(server=self)), name='simple-sso-verify'), 175 | ] 176 | -------------------------------------------------------------------------------- /simple_sso/utils.py: -------------------------------------------------------------------------------- 1 | import string 2 | from random import SystemRandom 3 | from urllib.parse import urlparse, urlunparse, urljoin 4 | 5 | import requests 6 | from django.conf import settings 7 | from django.http import HttpResponse 8 | from django.views.decorators.csrf import csrf_exempt 9 | from itsdangerous import TimedSerializer, SignatureExpired, BadSignature 10 | 11 | from simple_sso.exceptions import BadRequest, WebserviceError 12 | 13 | random = SystemRandom() 14 | 15 | KEY_CHARACTERS = string.ascii_letters + string.digits 16 | PUBLIC_KEY_HEADER = 'x-services-public-key' 17 | 18 | 19 | def default_gen_secret_key(length=40): 20 | return ''.join([random.choice(KEY_CHARACTERS) for _ in range(length)]) 21 | 22 | 23 | def gen_secret_key(length=40): 24 | generator = getattr(settings, 'SIMPLE_SSO_KEYGENERATOR', default_gen_secret_key) 25 | return generator(length) 26 | 27 | 28 | def _split_dsn(dsn): 29 | parse_result = urlparse(dsn) 30 | host = parse_result.hostname 31 | if parse_result.port: 32 | host += ':%s' % parse_result.port 33 | base_url = urlunparse(( 34 | parse_result.scheme, 35 | host, 36 | parse_result.path, 37 | parse_result.params, 38 | parse_result.query, 39 | parse_result.fragment, 40 | )) 41 | return base_url, parse_result.username, parse_result.password 42 | 43 | 44 | class BaseConsumer(object): 45 | def __init__(self, base_url, public_key, private_key): 46 | self.base_url = base_url 47 | self.public_key = public_key 48 | self.signer = TimedSerializer(private_key) 49 | 50 | @classmethod 51 | def from_dsn(cls, dsn): 52 | base_url, public_key, private_key = _split_dsn(dsn) 53 | return cls(base_url, public_key, private_key) 54 | 55 | def consume(self, path, data, max_age=None): 56 | if not path.startswith('/'): 57 | raise ValueError("Paths must start with a slash") 58 | signed_data = self.signer.dumps(data) 59 | headers = { 60 | PUBLIC_KEY_HEADER: self.public_key, 61 | 'Content-Type': 'application/json', 62 | } 63 | url = self.build_url(path) 64 | body = self.send_request(url, data=signed_data, headers=headers) 65 | return self.handle_response(body, max_age) 66 | 67 | def handle_response(self, body, max_age): 68 | return self.signer.loads(body, max_age=max_age) 69 | 70 | def send_request(self, url, data, headers): 71 | raise NotImplementedError( 72 | 'Implement send_request on BaseConsumer subclasses') 73 | 74 | @staticmethod 75 | def raise_for_status(status_code, message): 76 | if status_code == 400: 77 | raise BadRequest(message) 78 | elif status_code >= 300: 79 | raise WebserviceError(message) 80 | 81 | def build_url(self, path): 82 | path = path.lstrip('/') 83 | return urljoin(self.base_url, path) 84 | 85 | 86 | class SyncConsumer(BaseConsumer): 87 | def __init__(self, base_url, public_key, private_key): 88 | super(SyncConsumer, self).__init__(base_url, public_key, private_key) 89 | self.session = requests.session() 90 | 91 | def send_request(self, url, data, headers): # pragma: no cover 92 | response = self.session.post(url, data=data, headers=headers) 93 | self.raise_for_status(response.status_code, response.content) 94 | return response.content 95 | 96 | 97 | class BaseProvider(object): 98 | max_age = None 99 | 100 | def provide(self, data): 101 | raise NotImplementedError( 102 | 'Subclasses of services.models.Provider must implement ' 103 | 'the provide method' 104 | ) 105 | 106 | def get_private_key(self, public_key): 107 | raise NotImplementedError( 108 | 'Subclasses of services.models.Provider must implement ' 109 | 'the get_private_key method' 110 | ) 111 | 112 | def report_exception(self): 113 | pass 114 | 115 | def get_response(self, method, signed_data, get_header): 116 | if method != 'POST': 117 | return 405, ['POST'] 118 | public_key = get_header(PUBLIC_KEY_HEADER, None) 119 | if not public_key: 120 | return 400, "No public key" 121 | private_key = self.get_private_key(public_key) 122 | if not private_key: 123 | return 400, "Invalid public key" 124 | signer = TimedSerializer(private_key) 125 | try: 126 | data = signer.loads(signed_data, max_age=self.max_age) 127 | except SignatureExpired: 128 | return 400, "Signature expired" 129 | except BadSignature: 130 | return 400, "Bad Signature" 131 | try: 132 | raw_response_data = self.provide(data) 133 | except: 134 | self.report_exception() 135 | return 400, "Failed to process the request" 136 | response_data = signer.dumps(raw_response_data) 137 | return 200, response_data 138 | 139 | 140 | def provider_wrapper(provider): 141 | def provider_view(request): 142 | def get_header(key, default): 143 | django_key = 'HTTP_%s' % key.upper().replace('-', '_') 144 | return request.META.get(django_key, default) 145 | 146 | method = request.method 147 | if getattr(request, 'body', None): 148 | signed_data = request.body 149 | else: 150 | signed_data = request.raw_post_data 151 | status_code, data = provider.get_response( 152 | method, 153 | signed_data, 154 | get_header, 155 | ) 156 | return HttpResponse(data, status=status_code) 157 | 158 | return csrf_exempt(provider_view) 159 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divio/django-simple-sso/4037ed52fd7d11c0de105abc614bb4ecd042ab19/tests/__init__.py -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | tox 2 | coverage 3 | flake8 4 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | urlpatterns = [] 6 | 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | 'NAME': ':memory:' 11 | } 12 | } 13 | 14 | INSTALLED_APPS = [ 15 | 'django.contrib.contenttypes', 16 | 'django.contrib.auth', 17 | 'django.contrib.sessions', 18 | 'django.contrib.admin', 19 | 'django.contrib.messages', 20 | 'simple_sso.sso_server', 21 | 'simple_sso', 22 | 'tests', 23 | ] 24 | 25 | ROOT_URLCONF = 'tests.urls' 26 | 27 | TEMPLATES = [ 28 | { 29 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 30 | 'DIRS': [ 31 | os.path.join(os.path.dirname(__file__), 'templates') 32 | ], 33 | 'OPTIONS': { 34 | 'debug': True, 35 | 'context_processors': [ 36 | 'django.contrib.auth.context_processors.auth', 37 | 'django.template.context_processors.request', 38 | 'django.contrib.messages.context_processors.messages', 39 | ], 40 | 'loaders': ( 41 | 'django.template.loaders.filesystem.Loader', 42 | 'django.template.loaders.app_directories.Loader', 43 | ) 44 | }, 45 | }, 46 | ] 47 | 48 | MIDDLEWARES = [ 49 | 'django.contrib.sessions.middleware.SessionMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | ] 53 | 54 | 55 | def runtests(): 56 | from django import setup 57 | from django.conf import settings 58 | from django.test.utils import get_runner 59 | 60 | settings.configure( 61 | INSTALLED_APPS=INSTALLED_APPS, 62 | ROOT_URLCONF=ROOT_URLCONF, 63 | DATABASES=DATABASES, 64 | TEST_RUNNER='django.test.runner.DiscoverRunner', 65 | TEMPLATES=TEMPLATES, 66 | MIDDLEWARE=MIDDLEWARES, 67 | SSO_PRIVATE_KEY='private', 68 | SSO_PUBLIC_KEY='public', 69 | SSO_SERVER='http://localhost/server/', 70 | SECRET_KEY="secret-key-for-tests", 71 | ) 72 | setup() 73 | 74 | # Run the test suite, including the extra validation tests. 75 | TestRunner = get_runner(settings) 76 | 77 | test_runner = TestRunner(verbosity=1, interactive=False, failfast=False) 78 | failures = test_runner.run_tests(INSTALLED_APPS) 79 | return failures 80 | 81 | 82 | def run(): 83 | failures = runtests() 84 | sys.exit(failures) 85 | 86 | 87 | if __name__ == '__main__': 88 | run() 89 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | 3 | from django.conf import settings 4 | from django.contrib.auth import get_user 5 | from django.contrib.auth.hashers import is_password_usable 6 | from django.contrib.auth.models import User 7 | from django.http import HttpResponseRedirect, HttpResponse 8 | from django.test.testcases import TestCase 9 | from django.urls import reverse 10 | 11 | from simple_sso.sso_server.models import Token, Consumer 12 | from simple_sso.utils import gen_secret_key, SyncConsumer 13 | from tests.urls import test_client 14 | from tests.utils.context_managers import (SettingsOverride, 15 | UserLoginContext) 16 | 17 | 18 | class TestingConsumer(SyncConsumer): 19 | def __init__(self, test_client_, base_url, public_key, private_key): 20 | self.test_client = test_client_ 21 | super(SyncConsumer, self).__init__(base_url, public_key, private_key) 22 | 23 | def build_url(self, path): 24 | return path 25 | 26 | def send_request(self, url, data, headers): 27 | headers = { 28 | 'HTTP_%s' % header.upper().replace('-', '_'): value 29 | for header, value in headers.items() 30 | } 31 | response = self.test_client.post( 32 | url, 33 | data=data, 34 | content_type='application/json', 35 | **headers 36 | ) 37 | self.raise_for_status(response.status_code, response.content) 38 | return response.content 39 | 40 | 41 | class SimpleSSOTests(TestCase): 42 | urls = 'simple_sso.test_urls' 43 | 44 | def setUp(self): 45 | import requests 46 | 47 | def get(url, params={}, headers={}, cookies=None, auth=None, **kwargs): 48 | return self.client.get(url, params) 49 | requests.get = get 50 | test_client.consumer = TestingConsumer( 51 | self.client, test_client.server_url, test_client.public_key, test_client.private_key) 52 | 53 | def _get_consumer(self): 54 | return Consumer.objects.create( 55 | name='test', 56 | private_key=settings.SSO_PRIVATE_KEY, 57 | public_key=settings.SSO_PUBLIC_KEY, 58 | ) 59 | 60 | def test_walkthrough(self): 61 | USERNAME = PASSWORD = 'myuser' 62 | server_user = User.objects.create_user(USERNAME, 'my@user.com', PASSWORD) 63 | self._get_consumer() 64 | # verify theres no tokens yet 65 | self.assertEqual(Token.objects.count(), 0) 66 | response = self.client.get(reverse('simple-sso-login')) 67 | # there should be a token now 68 | self.assertEqual(Token.objects.count(), 1) 69 | # this should be a HttpResponseRedirect 70 | self.assertEqual(response.status_code, HttpResponseRedirect.status_code) 71 | # check that it's the URL we expect 72 | url = urlparse(response['Location']) 73 | path = url.path 74 | self.assertEqual(path, reverse('simple-sso-authorize')) 75 | # follow that redirect 76 | response = self.client.get(response['Location']) 77 | # now we should have another redirect to the login 78 | self.assertEqual(response.status_code, HttpResponseRedirect.status_code, response.content) 79 | # check that the URL is correct 80 | url = urlparse(response['Location']) 81 | path = url.path 82 | self.assertEqual(path, reverse('login')) 83 | # follow that redirect 84 | login_url = response['Location'] 85 | response = self.client.get(login_url) 86 | # now we should have a 200 87 | self.assertEqual(response.status_code, HttpResponse.status_code) 88 | # and log in using the username/password from above 89 | response = self.client.post(login_url, {'username': USERNAME, 'password': PASSWORD}) 90 | # now we should have a redirect back to the authorize view 91 | self.assertEqual(response.status_code, HttpResponseRedirect.status_code) 92 | # check that it's the URL we expect 93 | url = urlparse(response['Location']) 94 | path = url.path 95 | self.assertEqual(path, reverse('simple-sso-authorize')) 96 | # follow that redirect 97 | response = self.client.get(response['Location']) 98 | # this should again be a redirect 99 | self.assertEqual(response.status_code, HttpResponseRedirect.status_code) 100 | # this time back to the client app, confirm that! 101 | url = urlparse(response['Location']) 102 | path = url.path 103 | self.assertEqual(path, reverse('simple-sso-authenticate')) 104 | # follow it again 105 | response = self.client.get(response['Location']) 106 | # again a redirect! This time to / 107 | url = urlparse(response['Location']) 108 | path = url.path 109 | self.assertEqual(path, reverse('root')) 110 | # if we follow to root now, we should be logged in 111 | response = self.client.get(response['Location']) 112 | client_user = get_user(self.client) 113 | self.assertFalse(is_password_usable(client_user.password)) 114 | self.assertTrue(is_password_usable(server_user.password)) 115 | for key in ['username', 'email', 'first_name', 'last_name']: 116 | self.assertEqual(getattr(client_user, key), getattr(server_user, key)) 117 | 118 | def test_user_already_logged_in(self): 119 | USERNAME = PASSWORD = 'myuser' 120 | server_user = User.objects.create_user(USERNAME, 'my@user.com', PASSWORD) 121 | self._get_consumer() 122 | with UserLoginContext(self, server_user): 123 | # try logging in and auto-follow all 302s 124 | self.client.get(reverse('simple-sso-login'), follow=True) 125 | # check the user 126 | client_user = get_user(self.client) 127 | self.assertFalse(is_password_usable(client_user.password)) 128 | self.assertTrue(is_password_usable(server_user.password)) 129 | for key in ['username', 'email', 'first_name', 'last_name']: 130 | self.assertEqual(getattr(client_user, key), getattr(server_user, key)) 131 | 132 | def test_user_data_updated(self): 133 | """ User data update test 134 | 135 | Tests whether sso server user data changes will be forwared to the client on the user's next login. 136 | 137 | """ 138 | USERNAME = PASSWORD = 'myuser' 139 | extra_data = { 140 | "first_name": "bob", 141 | "last_name": "bobster", 142 | } 143 | server_user = User.objects.create_user( 144 | USERNAME, 145 | 'bob@bobster.org', 146 | PASSWORD, 147 | **extra_data, 148 | ) 149 | self._get_consumer() 150 | 151 | with UserLoginContext(self, server_user): 152 | # First login 153 | # try logging in and auto-follow all 302s 154 | self.client.get(reverse('simple-sso-login'), follow=True) 155 | # check the user 156 | client_user = get_user(self.client) 157 | for key in ['username', 'email', 'first_name', 'last_name']: 158 | self.assertEqual(getattr(client_user, key), getattr(server_user, key)) 159 | 160 | # User data changes 161 | server_user.first_name = "Alice" 162 | server_user.email = "alice@bobster.org" 163 | server_user.save() 164 | 165 | with UserLoginContext(self, server_user): 166 | # Second login 167 | self.client.get(reverse('simple-sso-login'), follow=True) 168 | client_user = get_user(self.client) 169 | for key in ['username', 'email', 'first_name', 'last_name']: 170 | self.assertEqual(getattr(client_user, key), getattr(server_user, key)) 171 | 172 | def test_custom_keygen(self): 173 | # WARNING: The following test uses a key generator function that is 174 | # highly insecure and should never under any circumstances be used in 175 | # a production enivornment 176 | with SettingsOverride(SIMPLE_SSO_KEYGENERATOR=lambda length: 'test'): 177 | self.assertEqual(gen_secret_key(40), 'test') 178 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | # original from 2 | # http://tech.octopus.energy/news/2016/01/21/testing-for-missing-migrations-in-django.html 3 | from io import StringIO 4 | 5 | from django.core.management import call_command 6 | from django.test import TestCase, override_settings 7 | 8 | 9 | class MigrationTestCase(TestCase): 10 | 11 | @override_settings(MIGRATION_MODULES={}) 12 | def test_for_missing_migrations(self): 13 | output = StringIO() 14 | options = { 15 | 'interactive': False, 16 | 'dry_run': True, 17 | 'stdout': output, 18 | 'check_changes': True, 19 | } 20 | 21 | try: 22 | call_command('makemigrations', **options) 23 | except SystemExit as e: 24 | status_code = str(e) 25 | else: 26 | # the "no changes" exit code is 0 27 | status_code = '0' 28 | 29 | if status_code == '1': 30 | self.fail('There are missing migrations:\n {}'.format(output.getvalue())) 31 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.contrib.auth.views import LoginView 4 | from django.http import HttpResponse 5 | from django.urls import re_path, include 6 | 7 | from simple_sso.sso_client.client import Client 8 | from simple_sso.sso_server.server import Server 9 | 10 | test_server = Server() 11 | test_client = Client(settings.SSO_SERVER, settings.SSO_PUBLIC_KEY, settings.SSO_PRIVATE_KEY) 12 | 13 | urlpatterns = [ 14 | re_path(r'^admin/', admin.site.urls), 15 | re_path(r'^server/', include(test_server.get_urls())), 16 | re_path(r'^client/', include(test_client.get_urls())), 17 | re_path(r'^login/$', LoginView.as_view(template_name='admin/login.html'), name="login"), 18 | re_path('^$', lambda request: HttpResponse('home'), name='root') 19 | ] 20 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divio/django-simple-sso/4037ed52fd7d11c0de105abc614bb4ecd042ab19/tests/utils/__init__.py -------------------------------------------------------------------------------- /tests/utils/context_managers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | class NULL: 5 | pass 6 | 7 | 8 | class SettingsOverride: 9 | """ 10 | Overrides Django settings within a context and resets them to their inital 11 | values on exit. 12 | 13 | Example: 14 | 15 | with SettingsOverride(DEBUG=True): 16 | # do something 17 | """ 18 | 19 | def __init__(self, **overrides): 20 | self.overrides = overrides 21 | 22 | def __enter__(self): 23 | self.old = {} 24 | for key, value in self.overrides.items(): 25 | self.old[key] = getattr(settings, key, NULL) 26 | setattr(settings, key, value) 27 | 28 | def __exit__(self, type, value, traceback): 29 | for key, value in self.old.items(): 30 | if value is not NULL: 31 | setattr(settings, key, value) 32 | else: 33 | delattr(settings, key) # do not pollute the context! 34 | 35 | 36 | class UserLoginContext: 37 | def __init__(self, testcase, user): 38 | self.testcase = testcase 39 | self.user = user 40 | 41 | def __enter__(self): 42 | loginok = self.testcase.client.login(username=self.user.username, 43 | password=self.user.username) 44 | self.old_user = getattr(self.testcase, 'user', None) 45 | self.testcase.user = self.user 46 | self.testcase.assertTrue(loginok) 47 | 48 | def __exit__(self, exc, value, tb): 49 | self.testcase.user = self.old_user 50 | if not self.testcase.user: 51 | delattr(self.testcase, 'user') 52 | self.testcase.client.logout() 53 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | flake8 4 | isort 5 | py{35,36,37,38}-dj{22} 6 | py{36,37,38}-dj{30,31} 7 | 8 | skip_missing_interpreters=True 9 | 10 | [flake8] 11 | max-line-length = 119 12 | exclude = 13 | *.egg-info, 14 | .eggs, 15 | .git, 16 | .settings, 17 | .tox, 18 | build, 19 | data, 20 | dist, 21 | docs, 22 | *migrations*, 23 | requirements, 24 | tmp 25 | 26 | [isort] 27 | line_length = 79 28 | skip = manage.py, *migrations*, .tox, .eggs, data 29 | include_trailing_comma = true 30 | multi_line_output = 5 31 | not_skip = __init__.py 32 | lines_after_imports = 2 33 | default_section = THIRDPARTY 34 | sections = FUTURE, STDLIB, DJANGO, THIRDPARTY, FIRSTPARTY, LIB, LOCALFOLDER 35 | known_first_party = simple_sso 36 | known_django = django 37 | 38 | [testenv] 39 | deps = 40 | -r{toxinidir}/tests/requirements.txt 41 | dj22: Django>=2.2,<3.0 42 | dj30: Django>=3.0,<3.1 43 | dj31: Django>=3.1,<3.2 44 | commands = 45 | {envpython} --version 46 | {env:COMMAND:coverage} erase 47 | {env:COMMAND:coverage} run setup.py test 48 | {env:COMMAND:coverage} report 49 | 50 | [testenv:flake8] 51 | deps = flake8 52 | commands = flake8 53 | 54 | [testenv:isort] 55 | deps = isort 56 | commands = isort -c -rc -df simple_sso 57 | skip_install = true 58 | -------------------------------------------------------------------------------- /travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | dist: xenial 4 | 5 | matrix: 6 | include: 7 | - python: 3.5 8 | env: TOX_ENV='flake8' 9 | - python: 3.5 10 | env: TOX_ENV='isort' 11 | # Django 2.2, run all supported versions for LTS releases 12 | - python: 3.5 13 | env: DJANGO='dj22' 14 | - python: 3.6 15 | env: DJANGO='dj22' 16 | - python: 3.7 17 | env: DJANGO='dj22' 18 | - python: 3.8 19 | env: DJANGO='dj22' 20 | # Django 3.0, always run the lowest supported version 21 | - python: 3.6 22 | env: DJANGO='dj30' 23 | # Django 3.1, always run the lowest supported version 24 | - python: 3.6 25 | env: DJANGO='dj31' 26 | 27 | install: 28 | - pip install coverage isort tox 29 | - "if [[ $TRAVIS_PYTHON_VERSION == '3.5' ]]; then export PY_VER=py35; fi" 30 | - "if [[ $TRAVIS_PYTHON_VERSION == '3.6' ]]; then export PY_VER=py36; fi" 31 | - "if [[ $TRAVIS_PYTHON_VERSION == '3.7' ]]; then export PY_VER=py37; fi" 32 | - "if [[ $TRAVIS_PYTHON_VERSION == '3.8' ]]; then export PY_VER=py38; fi" 33 | - "if [[ ${DJANGO}z != 'z' ]]; then export TOX_ENV=$PY_VER; fi" 34 | 35 | script: 36 | - tox -e $TOX_ENV 37 | 38 | after_success: 39 | - bash <(curl -s https://codecov.io/bash) 40 | --------------------------------------------------------------------------------