Skip to content

Commit 09165cb

Browse files
Initial commit
0 parents  commit 09165cb

File tree

7 files changed

+178
-0
lines changed

7 files changed

+178
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.egg-info
2+
__pycache__/

setup.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
setup(
9+
name='django-shared-session',
10+
version='0.1',
11+
packages=['shared_session'],
12+
include_package_data=True,
13+
license='MPL',
14+
description='A tool for cross domain Django session sharing',
15+
author='Viktor Stískala',
16+
author_email='viktor@stiskala.cz',
17+
install_requires=['django>=1.7', 'python-dateutil>=2.5'],
18+
classifiers=[
19+
'Intended Audience :: Developers',
20+
'Operating System :: OS Independent',
21+
'Programming Language :: Python',
22+
'Programming Language :: Python :: 3 :: Only',
23+
'Topic :: Software Development :: Libraries',
24+
'Topic :: Software Development :: Libraries :: Python Modules',
25+
],
26+
)

shared_session/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from django.conf.urls import url
2+
from . import views
3+
4+
urlpatterns = [
5+
url(r'^(?P<message>.+).js$', views.shared_session_view, name='share'),
6+
]
7+
8+
urls = urlpatterns, 'shared_session', 'shared_session'

shared_session/signals.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import django.dispatch
2+
3+
4+
session_replaced = django.dispatch.Signal(providing_args=['request', 'src_domain', 'dest_domain', 'was_empty'])

shared_session/templatetags/__init__.py

Whitespace-only changes.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import copy
2+
import json
3+
from 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 %}<script src="{{ path }}" async></script>{% endfor %}')
24+
25+
def __init__(self):
26+
self.encryption_key = settings.SECRET_KEY.encode('ascii')[:nacl.secret.SecretBox.KEY_SIZE]
27+
super().__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+
return urlsafe_base64_encode(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+
59+
def build_url(self, domain, message):
60+
return urljoin(domain, reverse('shared_session:share', kwargs={'message': message}))
61+
62+
def render(self, context):
63+
request = context['request']
64+
65+
if request.session.is_empty():
66+
return ''
67+
68+
try:
69+
self.ensure_session_key(request)
70+
71+
return self.template.render(template.Context({
72+
'domains': [self.build_url(domain='{}://{}'.format(request.scheme, domain), message=self.get_message(request, domain)) for domain in self.get_domains(request)]
73+
}))
74+
except UpdateError:
75+
return ''

shared_session/views.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import time
2+
import json
3+
import nacl.secret
4+
from dateutil.parser import parse
5+
6+
from django.conf import settings
7+
from django.http.response import HttpResponse
8+
from django.utils import timezone
9+
from django.utils.http import cookie_date, urlsafe_base64_decode
10+
from django.views import View
11+
from nacl.exceptions import CryptoError
12+
from . import signals
13+
14+
15+
class SharedSessionView(View):
16+
def __init__(self, **kwargs):
17+
self.encryption_key = settings.SECRET_KEY.encode('ascii')[:nacl.secret.SecretBox.KEY_SIZE]
18+
super().__init__(**kwargs)
19+
20+
def decrypt_payload(self, message):
21+
box = nacl.secret.SecretBox(self.encryption_key)
22+
23+
data = box.decrypt(message).decode('ascii')
24+
return json.loads(data)
25+
26+
def get(self, request, *args, **kwargs):
27+
response = HttpResponse('', content_type='text/javascript')
28+
try:
29+
message = self.decrypt_payload(urlsafe_base64_decode(kwargs.get('message')))
30+
31+
is_session_empty = request.session.is_empty()
32+
33+
# replace session cookie only when session is empty or when always replace is set
34+
if is_session_empty or getattr(settings, 'SHARED_SESSION_ALWAYS_REPLACE', False):
35+
http_host = request.META['HTTP_HOST']
36+
37+
if (timezone.now() - parse(message['ts'])).total_seconds() < getattr(settings, 'SHARED_SESSION_TIMEOUT', 30):
38+
if request.session.get_expire_at_browser_close():
39+
max_age = None
40+
expires = None
41+
else:
42+
max_age = request.session.get_expiry_age()
43+
expires_time = time.time() + max_age
44+
expires = cookie_date(expires_time)
45+
46+
response.set_cookie(
47+
settings.SESSION_COOKIE_NAME,
48+
message['key'], max_age=max_age,
49+
expires=expires, domain=settings.SESSION_COOKIE_DOMAIN,
50+
path=settings.SESSION_COOKIE_PATH,
51+
secure=settings.SESSION_COOKIE_SECURE or None,
52+
httponly=settings.SESSION_COOKIE_HTTPONLY or None,
53+
)
54+
55+
# emit signal
56+
signals.session_replaced.send(sender=self.__class__, request=request, was_empty=is_session_empty, src_domain=message['src'], dst_domain=http_host)
57+
except (CryptoError, ValueError):
58+
pass
59+
60+
return response
61+
62+
63+
shared_session_view = SharedSessionView.as_view()

0 commit comments

Comments
 (0)