├── .gitignore ├── LICENSE ├── README.md ├── setup.py └── shared_session ├── __init__.py ├── signals.py ├── templatetags ├── __init__.py └── shared_session.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | __pycache__/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This Source Code Form is subject to the terms of the Mozilla Public 2 | License, v. 2.0. If a copy of the MPL was not distributed with this 3 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-shared-session 2 | 3 | django-shared-session is a tool that enables cross site session sharing, which can be useful when running the same Django 4 | application on different domains (for example due to different language mutations). This library can be used for sharing login information as well as session data for both authenticated and anonymous users. If all you need is to share login information, please consider using some of single sign-on (SSO) solutions which could be better for this specific use case. 5 | 6 | This tool is only useful when you are running your application on different domains, not just subdomains. Subdomains can be handled with cookies path set to `.domain.tld` (starts with dot). 7 | 8 | This project is inspired by [django-xsession](https://github.com/badzong/django-xsession), but uses a different approach to session sharing which sets the cookie on server-side and thus does not require reloading the page. 9 | 10 | ## How it works 11 | 12 | 1. User visits one of the configured sites 13 | 2. Session key is encrypted and included in the HTML file. This file contains ` 20 | 21 | ``` 22 | Encrypted payload (containing session key, timestamp, source and destination hostname) in base64 is part of the filename itself. Destination server checks the timestamp to prevent replay attacks. 23 | 24 | ## Installation 25 | 26 | ```sh 27 | pip install django-shared-session 28 | ``` 29 | 30 | This tool accesses request inside template, so please make sure you have `RequestContext` enabled in your template's engine context processors. 31 | 32 | ## Usage 33 | Add `shared_session` to `INSTALLED_APPS` and set shared session domains in Django settings file. 34 | Then add `shared_session.urls` to your urlconf. 35 | 36 | settings.py: 37 | ```py 38 | INSTALLED_APPS = [ 39 | 'django.contrib.admin', 40 | 'django.contrib.auth', 41 | # ... 42 | 'shared_session' 43 | ] 44 | 45 | SHARED_SESSION_SITES = ['www.example.com', 'www.example.org'] 46 | ``` 47 | 48 | urls.py: 49 | ```py 50 | import shared_session 51 | 52 | urlpatterns = [ 53 | url(r'^shared-session/', shared_session.urls), # feel free to change the base url 54 | ] 55 | ``` 56 | 57 | In order to share sessions with configured sites you also need to use `{% shared_session_loader %}` in your base template. 58 | 59 | layout.html: 60 | ```html 61 | {% load shared_session %} 62 | 63 | 64 | 65 | 66 | 67 | {% shared_session_loader %} 68 | 69 | 70 | 71 | 72 | 73 | ``` 74 | 75 | If you want to share sessions also in Django admin interface, you can overwrite `admin/base_site.html` and include the loader. 76 | 77 | ## Advanced options 78 | 79 | `SHARED_SESSION_ALWAYS_REPLACE` – Always replace session cookie, even if the session is not empty. (default: False) 80 | `SHARED_SESSION_TIMEOUT` – Expiration timeout. Session needs to be delivered to destination site before this time. (default: 30) 81 | 82 | ### Signals 83 | 84 | Signal `session_replaced` is triggered when target's site session cookie was changed or created. 85 | You can connect your own handlers to run additional functions. 86 | 87 | ```py 88 | from shared_session import signals 89 | import logging 90 | 91 | def log_session_replace(sender, **kwargs): 92 | logging.info('%s session replaced' % kwargs.get('dst_domain')) 93 | 94 | signals.session_replaced.connect(log_session_replace) 95 | ``` 96 | 97 | ## License 98 | 99 | This software is licensed under MPL 2.0. 100 | 101 | - http://mozilla.org/MPL/2.0/ 102 | - http://www.mozilla.org/MPL/2.0/FAQ.html#use 103 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import os 3 | from setuptools import setup 4 | 5 | # allow setup.py to be run from any path 6 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 7 | 8 | with open(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'README.md'), 'r') as f: 9 | long_description = f.read() 10 | 11 | setup( 12 | name='django-shared-session', 13 | version='0.5.3', 14 | packages=['shared_session', 'shared_session.templatetags'], 15 | include_package_data=True, 16 | license='MPL', 17 | description='Django session sharing across multiple domains running same application', 18 | long_description=long_description, 19 | long_description_content_type='text/markdown', 20 | url='https://github.com/ViktorStiskala/django-shared-session', 21 | author='Viktor Stískala', 22 | author_email='viktor@stiskala.cz', 23 | install_requires=[ 24 | 'setuptools-git', 25 | 'django>=1.8', 26 | 'python-dateutil>=2.5', 27 | 'PyNaCl>=1.0.0', 28 | 'six>=1.11' 29 | ], 30 | classifiers=[ 31 | 'Intended Audience :: Developers', 32 | 'Operating System :: OS Independent', 33 | 'Programming Language :: Python', 34 | 'Programming Language :: Python :: 3', 35 | 'Programming Language :: Python :: 2.6', 36 | 'Programming Language :: Python :: 2.7', 37 | 'Topic :: Software Development :: Libraries', 38 | 'Topic :: Software Development :: Libraries :: Python Modules', 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /shared_session/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from . import views 3 | 4 | urlpatterns = [ 5 | url(r'^(?P.+).js$', views.shared_session_view, name='share'), 6 | ] 7 | 8 | urls = urlpatterns, 'shared_session', 'shared_session' 9 | -------------------------------------------------------------------------------- /shared_session/signals.py: -------------------------------------------------------------------------------- 1 | import django.dispatch 2 | 3 | 4 | session_replaced = django.dispatch.Signal(providing_args=['request', 'src_domain', 'dest_domain', 'was_empty']) 5 | -------------------------------------------------------------------------------- /shared_session/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ViktorStiskala/django-shared-session/108197b5c90a6b97ba3fe84c4b1213bd4272ff13/shared_session/templatetags/__init__.py -------------------------------------------------------------------------------- /shared_session/templatetags/shared_session.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | from six.moves.urllib.parse import urljoin 4 | 5 | import nacl.secret 6 | import nacl.utils 7 | from django import template 8 | from django.conf import settings 9 | from django.contrib.sessions.backends.base import UpdateError 10 | from django.urls import reverse 11 | from django.utils import timezone 12 | from django.utils.http import urlsafe_base64_encode 13 | 14 | register = template.Library() 15 | 16 | 17 | @register.tag 18 | def shared_session_loader(parser, token): 19 | return LoaderNode() 20 | 21 | 22 | class LoaderNode(template.Node): 23 | template = template.Template('{% for path in domains %}{% endfor %}') 24 | 25 | def __init__(self): 26 | self.encryption_key = settings.SECRET_KEY.encode('ascii')[:nacl.secret.SecretBox.KEY_SIZE] 27 | super(LoaderNode, self).__init__() 28 | 29 | def get_domains(self, request): 30 | host = request.META['HTTP_HOST'] 31 | 32 | # Build domain list, with support for subdomains 33 | domains = copy.copy(settings.SHARED_SESSION_SITES) 34 | for domain in settings.SHARED_SESSION_SITES: 35 | if host.endswith(domain): 36 | domains.remove(domain) 37 | 38 | return domains 39 | 40 | def ensure_session_key(self, request): 41 | if not request.session.session_key: 42 | request.session.save() 43 | 44 | def encrypt_payload(self, data): 45 | box = nacl.secret.SecretBox(self.encryption_key) 46 | nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE) 47 | 48 | message = json.dumps(data).encode('ascii') 49 | return box.encrypt(message, nonce) 50 | 51 | def get_message(self, request, domain): 52 | enc_payload = self.encrypt_payload({ 53 | 'key': request.session.session_key, 54 | 'src': request.META['HTTP_HOST'], 55 | 'dst': domain, 56 | 'ts': timezone.now().isoformat() 57 | }) 58 | data = urlsafe_base64_encode(enc_payload) 59 | try: 60 | return data.decode('ascii') # Django versions prior to 2.2 don't return `str` automatically 61 | except AttributeError: 62 | return data 63 | 64 | 65 | def build_url(self, domain, message): 66 | return urljoin(domain, reverse('shared_session:share', kwargs={'message': message})) 67 | 68 | def render(self, context): 69 | request = context['request'] 70 | 71 | if request.session.is_empty(): 72 | return '' 73 | 74 | try: 75 | self.ensure_session_key(request) 76 | 77 | return self.template.render(template.Context({ 78 | 'domains': [self.build_url(domain='{}://{}'.format(request.scheme, domain), message=self.get_message(request, domain)) for domain in self.get_domains(request)] 79 | })) 80 | except UpdateError: 81 | return '' 82 | -------------------------------------------------------------------------------- /shared_session/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | 4 | import nacl.secret 5 | from dateutil.parser import parse 6 | from django.conf import settings 7 | from django.http import HttpResponse 8 | from django.middleware.csrf import get_token 9 | from django.utils import timezone 10 | from django.utils.http import http_date, urlsafe_base64_decode 11 | from django.views.generic.base import View 12 | from nacl.exceptions import CryptoError 13 | 14 | from . import signals 15 | 16 | 17 | class SharedSessionView(View): 18 | def __init__(self, **kwargs): 19 | self.encryption_key = settings.SECRET_KEY.encode('ascii')[:nacl.secret.SecretBox.KEY_SIZE] 20 | super(SharedSessionView, self).__init__(**kwargs) 21 | 22 | def decrypt_payload(self, message): 23 | box = nacl.secret.SecretBox(self.encryption_key) 24 | 25 | data = box.decrypt(message).decode('ascii') 26 | return json.loads(data) 27 | 28 | def get(self, request, *args, **kwargs): 29 | response = HttpResponse('', content_type='text/javascript') 30 | try: 31 | message = self.decrypt_payload(urlsafe_base64_decode(kwargs.get('message'))) 32 | 33 | is_session_empty = request.session.is_empty() 34 | 35 | # replace session cookie only when session is empty or when always replace is set 36 | if is_session_empty or getattr(settings, 'SHARED_SESSION_ALWAYS_REPLACE', False): 37 | http_host = request.META['HTTP_HOST'] 38 | 39 | if (timezone.now() - parse(message['ts'])).total_seconds() < getattr(settings, 'SHARED_SESSION_TIMEOUT', 30): 40 | if request.session.get_expire_at_browser_close(): 41 | max_age = None 42 | expires = None 43 | else: 44 | max_age = request.session.get_expiry_age() 45 | expires_time = time.time() + max_age 46 | expires = http_date(expires_time) 47 | 48 | response.set_cookie( 49 | settings.SESSION_COOKIE_NAME, 50 | message['key'], max_age=max_age, 51 | expires=expires, domain=settings.SESSION_COOKIE_DOMAIN, 52 | path=settings.SESSION_COOKIE_PATH, 53 | secure=settings.SESSION_COOKIE_SECURE or None, 54 | httponly=settings.SESSION_COOKIE_HTTPONLY or None, 55 | ) 56 | 57 | # ensure CSRF cookie is set 58 | get_token(request) 59 | 60 | # emit signal 61 | signals.session_replaced.send(sender=self.__class__, request=request, was_empty=is_session_empty, src_domain=message['src'], dst_domain=http_host) 62 | except (CryptoError, ValueError): 63 | pass 64 | 65 | return response 66 | 67 | 68 | shared_session_view = SharedSessionView.as_view() 69 | --------------------------------------------------------------------------------