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 <https://github.com/divio/django-simple-sso/blob/master/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)
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
SYMBOL INDEX (100 symbols across 13 files)
FILE: simple_sso/exceptions.py
class WebserviceError (line 1) | class WebserviceError(Exception):
class BadRequest (line 5) | class BadRequest(WebserviceError):
FILE: simple_sso/sso_client/client.py
class LoginView (line 16) | class LoginView(View):
method get (line 19) | def get(self, request):
method get_next (line 31) | def get_next(self):
class AuthenticateView (line 48) | class AuthenticateView(LoginView):
method get (line 51) | def get(self, request):
class Client (line 61) | class Client:
method __init__ (line 67) | def __init__(self, server_url, public_key, private_key,
method from_dsn (line 77) | def from_dsn(cls, dsn):
method get_request_token (line 88) | def get_request_token(self, redirect_to):
method get_user (line 96) | def get_user(self, access_token):
method build_user (line 110) | def build_user(self, user_data):
method get_urls (line 125) | def get_urls(self):
FILE: simple_sso/sso_server/apps.py
class SimpleSSOServer (line 4) | class SimpleSSOServer(AppConfig):
FILE: simple_sso/sso_server/migrations/0001_initial.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: simple_sso/sso_server/migrations/0002_consumer_name_max_length.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: simple_sso/sso_server/migrations/0003_token_redirect_to_max_length.py
class Migration (line 4) | class Migration(migrations.Migration):
FILE: simple_sso/sso_server/models.py
class SecretKeyGenerator (line 10) | class SecretKeyGenerator:
method __init__ (line 15) | def __init__(self, field):
method __call__ (line 18) | def __call__(self):
class ConsumerSecretKeyGenerator (line 25) | class ConsumerSecretKeyGenerator(SecretKeyGenerator):
method get_model (line 26) | def get_model(self):
class TokenSecretKeyGenerator (line 30) | class TokenSecretKeyGenerator(SecretKeyGenerator):
method get_model (line 31) | def get_model(self):
class Consumer (line 35) | class Consumer(models.Model):
method __unicode__ (line 46) | def __unicode__(self):
method rotate_keys (line 49) | def rotate_keys(self):
class Token (line 55) | class Token(models.Model):
method refresh (line 77) | def refresh(self):
FILE: simple_sso/sso_server/server.py
class Provider (line 17) | class Provider(BaseProvider):
method __init__ (line 20) | def __init__(self, server):
method get_private_key (line 23) | def get_private_key(self, public_key):
class RequestTokenProvider (line 31) | class RequestTokenProvider(Provider):
method provide (line 32) | def provide(self, data):
class AuthorizeView (line 38) | class AuthorizeView(View):
method get (line 50) | def get(self, request):
method missing_token_argument (line 66) | def missing_token_argument(self):
method token_not_found (line 69) | def token_not_found(self):
method token_timeout (line 72) | def token_timeout(self):
method check_token_timeout (line 75) | def check_token_timeout(self):
method handle_authenticated_user (line 83) | def handle_authenticated_user(self):
method handle_unauthenticated_user (line 89) | def handle_unauthenticated_user(self):
method access_denied (line 94) | def access_denied(self):
method success (line 97) | def success(self):
class VerificationProvider (line 108) | class VerificationProvider(Provider, AuthorizeView):
method provide (line 109) | def provide(self, data):
method token_not_bound (line 123) | def token_not_bound(self):
class ConsumerAdmin (line 127) | class ConsumerAdmin(ModelAdmin):
class Server (line 131) | class Server:
method __init__ (line 139) | def __init__(self, **kwargs):
method register_admin (line 144) | def register_admin(self):
method has_access (line 147) | def has_access(self, user, consumer):
method get_user_extra_data (line 150) | def get_user_extra_data(self, user, consumer, extra_data):
method get_user_data (line 153) | def get_user_data(self, user, consumer, extra_data=None):
method get_urls (line 168) | def get_urls(self):
FILE: simple_sso/utils.py
function default_gen_secret_key (line 19) | def default_gen_secret_key(length=40):
function gen_secret_key (line 23) | def gen_secret_key(length=40):
function _split_dsn (line 28) | def _split_dsn(dsn):
class BaseConsumer (line 44) | class BaseConsumer(object):
method __init__ (line 45) | def __init__(self, base_url, public_key, private_key):
method from_dsn (line 51) | def from_dsn(cls, dsn):
method consume (line 55) | def consume(self, path, data, max_age=None):
method handle_response (line 67) | def handle_response(self, body, max_age):
method send_request (line 70) | def send_request(self, url, data, headers):
method raise_for_status (line 75) | def raise_for_status(status_code, message):
method build_url (line 81) | def build_url(self, path):
class SyncConsumer (line 86) | class SyncConsumer(BaseConsumer):
method __init__ (line 87) | def __init__(self, base_url, public_key, private_key):
method send_request (line 91) | def send_request(self, url, data, headers): # pragma: no cover
class BaseProvider (line 97) | class BaseProvider(object):
method provide (line 100) | def provide(self, data):
method get_private_key (line 106) | def get_private_key(self, public_key):
method report_exception (line 112) | def report_exception(self):
method get_response (line 115) | def get_response(self, method, signed_data, get_header):
function provider_wrapper (line 140) | def provider_wrapper(provider):
FILE: tests/settings.py
function runtests (line 55) | def runtests():
function run (line 82) | def run():
FILE: tests/test_core.py
class TestingConsumer (line 18) | class TestingConsumer(SyncConsumer):
method __init__ (line 19) | def __init__(self, test_client_, base_url, public_key, private_key):
method build_url (line 23) | def build_url(self, path):
method send_request (line 26) | def send_request(self, url, data, headers):
class SimpleSSOTests (line 41) | class SimpleSSOTests(TestCase):
method setUp (line 44) | def setUp(self):
method _get_consumer (line 53) | def _get_consumer(self):
method test_walkthrough (line 60) | def test_walkthrough(self):
method test_user_already_logged_in (line 118) | def test_user_already_logged_in(self):
method test_user_data_updated (line 132) | def test_user_data_updated(self):
method test_custom_keygen (line 172) | def test_custom_keygen(self):
FILE: tests/test_migrations.py
class MigrationTestCase (line 9) | class MigrationTestCase(TestCase):
method test_for_missing_migrations (line 12) | def test_for_missing_migrations(self):
FILE: tests/utils/context_managers.py
class NULL (line 4) | class NULL:
class SettingsOverride (line 8) | class SettingsOverride:
method __init__ (line 19) | def __init__(self, **overrides):
method __enter__ (line 22) | def __enter__(self):
method __exit__ (line 28) | def __exit__(self, type, value, traceback):
class UserLoginContext (line 36) | class UserLoginContext:
method __init__ (line 37) | def __init__(self, testcase, user):
method __enter__ (line 41) | def __enter__(self):
method __exit__ (line 48) | def __exit__(self, exc, value, tb):
Condensed preview — 32 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (51K chars).
[
{
"path": ".coveragerc",
"chars": 298,
"preview": "[run]\nbranch = True\nsource = simple_sso\nomit =\n migrations/*\n tests/*\n\n[report]\nexclude_lines =\n pragma: no cov"
},
{
"path": ".editorconfig",
"chars": 426,
"preview": "# editorconfig.org\n\nroot = true\n\n[*]\nindent_style = space\nindent_size = 4\nend_of_line = lf\ncharset = utf-8\ntrim_trailing"
},
{
"path": ".gitignore",
"chars": 175,
"preview": "*.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.settin"
},
{
"path": "CHANGELOG.rst",
"chars": 702,
"preview": "=========\nChangelog\n=========\n\n1.3.0 (2025-05-05)\n==================\n\n* Remove the abandoned dependency `webservices`, c"
},
{
"path": "LICENSE",
"chars": 1486,
"preview": "Copyright (c) 2011, Jonas Obrist\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or withou"
},
{
"path": "MANIFEST.in",
"chars": 64,
"preview": "include LICENSE\ninclude README.rst\nrecursive-exclude * *.py[co]\n"
},
{
"path": "README.rst",
"chars": 6805,
"preview": "=================\ndjango-simple-sso\n=================\n\n|pypi| |build| |coverage|\n\n\nDocumentation\n=============\n\nSee ``RE"
},
{
"path": "setup.py",
"chars": 1467,
"preview": "#!/usr/bin/env python\nfrom setuptools import find_packages, setup\n\nfrom simple_sso import __version__\n\n\nREQUIREMENTS = ["
},
{
"path": "simple_sso/__init__.py",
"chars": 22,
"preview": "__version__ = '1.3.0'\n"
},
{
"path": "simple_sso/exceptions.py",
"chars": 89,
"preview": "class WebserviceError(Exception):\n pass\n\n\nclass BadRequest(WebserviceError):\n pass\n"
},
{
"path": "simple_sso/models.py",
"chars": 45,
"preview": "\"\"\"\nThis is only here so I can run tests\n\"\"\"\n"
},
{
"path": "simple_sso/sso_client/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "simple_sso/sso_client/client.py",
"chars": 4713,
"preview": "from copy import copy\nfrom urllib.parse import urlparse, urlunparse, urljoin, urlencode\n\nfrom django.urls import re_path"
},
{
"path": "simple_sso/sso_server/__init__.py",
"chars": 66,
"preview": "default_app_config = 'simple_sso.sso_server.apps.SimpleSSOServer'\n"
},
{
"path": "simple_sso/sso_server/apps.py",
"chars": 105,
"preview": "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",
"chars": 1833,
"preview": "from django.db import migrations, models\nfrom django.utils import timezone\nfrom django.conf import settings\nimport simpl"
},
{
"path": "simple_sso/sso_server/migrations/0002_consumer_name_max_length.py",
"chars": 343,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n dependencies = [\n ('sso_se"
},
{
"path": "simple_sso/sso_server/migrations/0003_token_redirect_to_max_length.py",
"chars": 352,
"preview": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n dependencies = [\n ('sso_se"
},
{
"path": "simple_sso/sso_server/migrations/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "simple_sso/sso_server/models.py",
"chars": 2121,
"preview": "from django.conf import settings\nfrom django.db import models\nfrom django.utils import timezone\nfrom django.utils.decons"
},
{
"path": "simple_sso/sso_server/server.py",
"chars": 6222,
"preview": "import datetime\nfrom urllib.parse import urlparse, urlencode, urlunparse\n\nfrom django.contrib import admin\nfrom django.c"
},
{
"path": "simple_sso/utils.py",
"chars": 5067,
"preview": "import string\nfrom random import SystemRandom\nfrom urllib.parse import urlparse, urlunparse, urljoin\n\nimport requests\nfr"
},
{
"path": "tests/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/requirements.txt",
"chars": 20,
"preview": "tox\ncoverage\nflake8\n"
},
{
"path": "tests/settings.py",
"chars": 2192,
"preview": "import os\nimport sys\n\n\nurlpatterns = []\n\nDATABASES = {\n 'default': {\n 'ENGINE': 'django.db.backends.sqlite3',\n"
},
{
"path": "tests/test_core.py",
"chars": 7471,
"preview": "from urllib.parse import urlparse\n\nfrom django.conf import settings\nfrom django.contrib.auth import get_user\nfrom django"
},
{
"path": "tests/test_migrations.py",
"chars": 885,
"preview": "# original from\n# http://tech.octopus.energy/news/2016/01/21/testing-for-missing-migrations-in-django.html\nfrom io impor"
},
{
"path": "tests/urls.py",
"chars": 747,
"preview": "from django.conf import settings\nfrom django.contrib import admin\nfrom django.contrib.auth.views import LoginView\nfrom d"
},
{
"path": "tests/utils/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/utils/context_managers.py",
"chars": 1453,
"preview": "from django.conf import settings\n\n\nclass NULL:\n pass\n\n\nclass SettingsOverride:\n \"\"\"\n Overrides Django settings "
},
{
"path": "tox.ini",
"chars": 1073,
"preview": "[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=Tr"
},
{
"path": "travis.yml",
"chars": 1082,
"preview": "language: python\n\ndist: xenial\n\nmatrix:\n include:\n - python: 3.5\n env: TOX_ENV='flake8'\n - python: 3.5\n "
}
]
About this extraction
This page contains the full source code of the divio/django-simple-sso GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 32 files (46.2 KB), approximately 11.5k tokens, and a symbol index with 100 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.