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