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