├── __init__.py ├── dynamodb_sessions ├── models.py ├── backends │ ├── __init__.py │ ├── cached_dynamodb.py │ └── dynamodb.py ├── __init__.py └── tests.py ├── requirements.txt ├── MANIFEST.in ├── .gitignore ├── setup.py ├── LICENSE └── README.rst /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dynamodb_sessions/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3 2 | django -------------------------------------------------------------------------------- /dynamodb_sessions/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include requirements.txt -------------------------------------------------------------------------------- /dynamodb_sessions/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'gtaylor' 2 | # Major, minor 3 | __version__ = (0, 7) 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | *.tmp 4 | *~ 5 | *.pydevproject 6 | *.project 7 | .idea 8 | MANIFEST 9 | build 10 | dist 11 | boto 12 | testapp/ 13 | manage.py 14 | *.egg-info 15 | -------------------------------------------------------------------------------- /dynamodb_sessions/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sessions.tests import SessionTestsMixin 2 | from django.test import TestCase 3 | 4 | from .backends.dynamodb import SessionStore as DynamoDBSession 5 | from .backends.cached_dynamodb import SessionStore as CachedDynamoDBSession 6 | 7 | 8 | class DynamoDBTestCase(SessionTestsMixin, TestCase): 9 | 10 | backend = DynamoDBSession 11 | 12 | 13 | class CachedDynamoDBTestCase(SessionTestsMixin, TestCase): 14 | 15 | backend = CachedDynamoDBSession 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import dynamodb_sessions 3 | 4 | long_description = open('README.rst').read() 5 | 6 | major_ver, minor_ver = dynamodb_sessions.__version__ 7 | version_str = '%d.%d' % (major_ver, minor_ver) 8 | 9 | setup( 10 | name='django-dynamodb-sessions', 11 | version=version_str, 12 | packages=find_packages(), 13 | description="A Django session backend using Amazon's DynamoDB", 14 | long_description=long_description, 15 | author='Gregory Taylor', 16 | author_email='gtaylor@gc-taylor.com', 17 | license='BSD License', 18 | url='https://github.com/gtaylor/django-dynamodb-sessions', 19 | platforms=["any"], 20 | install_requires=['django', "boto3>=1.1.4"], 21 | classifiers=[ 22 | 'Development Status :: 4 - Beta', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: BSD License', 25 | 'Natural Language :: English', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python', 28 | 'Environment :: Web Environment', 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Gregory Taylor 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * Neither the name of the DUO Interactive, LLC nor the names of its 13 | contributors may be used to endorse or promote products derived from this 14 | software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /dynamodb_sessions/backends/cached_dynamodb.py: -------------------------------------------------------------------------------- 1 | """ 2 | Cached, DynamoDB-backed sessions. 3 | """ 4 | 5 | from django.conf import settings 6 | from django.core.cache import cache 7 | 8 | from dynamodb_sessions.backends.dynamodb import SessionStore as DynamoDBStore 9 | 10 | KEY_PREFIX = "dynamodb_sessions.backends.cached_dynamodb" 11 | 12 | 13 | class SessionStore(DynamoDBStore): 14 | """ 15 | Implements cached, database backed sessions. 16 | """ 17 | 18 | def __init__(self, session_key=None): 19 | super(SessionStore, self).__init__(session_key) 20 | 21 | @property 22 | def cache_key(self): 23 | return KEY_PREFIX + self.session_key 24 | 25 | def load(self): 26 | data = cache.get(self.cache_key, None) 27 | if data is None: 28 | data = super(SessionStore, self).load() 29 | cache.set(self.cache_key, data, settings.SESSION_COOKIE_AGE) 30 | return data 31 | 32 | def exists(self, session_key): 33 | if (KEY_PREFIX + session_key) in cache: 34 | return True 35 | return super(SessionStore, self).exists(session_key) 36 | 37 | def save(self, must_create=False): 38 | super(SessionStore, self).save(must_create) 39 | cache.set(self.cache_key, self._session, settings.SESSION_COOKIE_AGE) 40 | 41 | def delete(self, session_key=None): 42 | super(SessionStore, self).delete(session_key) 43 | if session_key is None: 44 | if self.session_key is None: 45 | return 46 | session_key = self.session_key 47 | cache.delete(KEY_PREFIX + session_key) 48 | 49 | def flush(self): 50 | """ 51 | Removes the current session data from the database and regenerates the 52 | key. 53 | """ 54 | 55 | self.clear() 56 | self.delete(self.session_key) 57 | self.create() 58 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-dynamodb-sessions 2 | ======================== 3 | 4 | :Info: This package contains a simple Django session backend that uses 5 | Amazon's `DynamoDB`_ for data storage. 6 | :Author: Greg Taylor 7 | :Status: Stable, but un-maintained (Open an issue if interested in maintaining!) 8 | 9 | .. _DynamoDB: http://aws.amazon.com/dynamodb/ 10 | 11 | Status 12 | ------ 13 | 14 | django-dynamodb-sessions has seen some use on small test environments within 15 | EC2. While it should be ready for prime time, it hasn't been heavily battle 16 | tested just yet. Other notes: 17 | 18 | * There is currently no management command to remove expired sessions. We 19 | can't re-use the Django cleanup command, so we'll have to write our own. 20 | This will be added in the next release, we're already setting expiration 21 | attributes to drive the cleanup. 22 | 23 | Set up your DynamoDB Table 24 | -------------------------- 25 | 26 | Before you can use this module, you'll need to visit your `DynamoDB tab`_ 27 | in the AWS Management Console. Then: 28 | 29 | * Hit the *Create Table* button. 30 | * Enter ``sessions`` as your table name. This can be something else, you'll 31 | just need to adjust the ``settings.DYNAMODB_SESSIONS_TABLE_NAME`` value 32 | accordingly. 33 | * Select Primary Key Type = ``Hash``. 34 | * Select a ``String`` hash attribute type. 35 | * Enter ``session_key`` for *Hash Attribute Name*. 36 | * Hit the *Continue* button. 37 | * Decide on throughput. The free tier is 10 read capacity units, 5 write. 38 | * Finish the rest of the steps 39 | 40 | After your table is created, you're ready to install the module on your 41 | Django app. 42 | 43 | .. _DynamoDB tab: https://console.aws.amazon.com/dynamodb/home 44 | 45 | Installation 46 | ------------- 47 | 48 | Install django-dynamodb-sessions using ``pip`` or ``easy_install``:: 49 | 50 | pip install django-dynamodb-sessions 51 | 52 | In your ``settings.py`` file, you'll need something like this:: 53 | 54 | DYNAMODB_SESSIONS_AWS_ACCESS_KEY_ID = 'YourKeyIDHere' 55 | DYNAMODB_SESSIONS_AWS_SECRET_ACCESS_KEY = 'YourSecretHere' 56 | 57 | If you'd like to add a caching layer between your application and DynamoDB 58 | to reduce queries (like Django's cached_db backend), set your session 59 | backend to:: 60 | 61 | SESSION_ENGINE = 'dynamodb_sessions.backends.cached_dynamodb' 62 | 63 | Otherwise, go straight to DynamoDB:: 64 | 65 | SESSION_ENGINE = 'dynamodb_sessions.backends.dynamodb' 66 | 67 | After that, fire her up and keep an eye on your Amazon Management Console 68 | to see if you need to scale your read/write units up or down. 69 | 70 | If you encounter any bugs, have questions, or would like to share an idea, 71 | hit up our `issue tracker`_. 72 | 73 | .. _Boto3: https://github.com/boto/boto3 74 | .. _issue tracker: https://github.com/gtaylor/django-dynamodb-sessions/issues 75 | 76 | Configuration 77 | ------------- 78 | 79 | The following settings may be used in your ``settings.py``: 80 | 81 | :DYNAMODB_SESSIONS_TABLE_NAME: The table name to use for session data storage. 82 | Defaults to ``sessions``. 83 | :DYNAMODB_SESSIONS_TABLE_HASH_ATTRIB_NAME: The hash attribute name on your 84 | session table. Defaults 85 | to ``session_key`` 86 | :DYNAMODB_SESSIONS_ALWAYS_CONSISTENT: If you're not using this session backend 87 | behind a cache, you may want to force all 88 | reads from DynamoDB to be consistent. 89 | This may lead to slightly slower queries, 90 | but you'll never miss object 91 | creation/edits. Defaults to ``True``. 92 | :DYNAMODB_SESSIONS_BOTO_SESSION: Used instead of providing access_key and 93 | region, the `boto3.session.Session `_ 94 | containing authentication for the AWS account 95 | to use for DynamoDB. 96 | :DYNAMODB_SESSIONS_AWS_ACCESS_KEY_ID: The access key for the AWS account 97 | to use for DynamoDB. 98 | :DYNAMODB_SESSIONS_AWS_SECRET_ACCESS_KEY: The secret for the AWS account 99 | to use for DynamoDB. 100 | :DYNAMODB_SESSIONS_AWS_REGION_NAME: The region to use for DynamoDB. 101 | 102 | 103 | Changes 104 | ------- 105 | 106 | 0.7 107 | ^^^ 108 | 109 | * Switch to boto3 rather than boto for AWS access. 110 | * Allow passing of boto3 session rather than AWS credentials. 111 | 112 | 0.6 113 | ^^^ 114 | 115 | * Removing some no longer used imports. 116 | * PEP8 cleanup. 117 | 118 | 0.5 119 | ^^^ 120 | 121 | * Replacing self.session_key with self._session_key in the backend. (AdamN) 122 | 123 | 0.4 124 | ^^^ 125 | 126 | * Django 1.4 compatibility, and unnecessary code removal. (AdamN) 127 | 128 | 0.3 129 | ^^^ 130 | 131 | * Re-packaging with setuptools instead of distutils. 132 | 133 | 0.2 134 | ^^^ 135 | 136 | * Correcting an issue with the cached_dynamodb backend. 137 | 138 | 0.1 139 | ^^^ 140 | 141 | * Initial release. 142 | 143 | License 144 | ------- 145 | 146 | django-dynamodb-sessions is licensed under the `BSD License`_. 147 | 148 | .. _BSD License: https://github.com/gtaylor/django-dynamodb-sessions/blob/master/LICENSE 149 | -------------------------------------------------------------------------------- /dynamodb_sessions/backends/dynamodb.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | 4 | from django.conf import settings 5 | from django.contrib.sessions.backends.base import SessionBase, CreateError 6 | 7 | from botocore.exceptions import ClientError 8 | from boto3.dynamodb.conditions import Attr as DynamoConditionAttr 9 | from boto3.session import Session as Boto3Session 10 | 11 | 12 | TABLE_NAME = getattr( 13 | settings, 'DYNAMODB_SESSIONS_TABLE_NAME', 'sessions') 14 | HASH_ATTRIB_NAME = getattr( 15 | settings, 'DYNAMODB_SESSIONS_TABLE_HASH_ATTRIB_NAME', 'session_key') 16 | ALWAYS_CONSISTENT = getattr( 17 | settings, 'DYNAMODB_SESSIONS_ALWAYS_CONSISTENT', True) 18 | 19 | _BOTO_SESSION = getattr( 20 | settings, 'DYNAMODB_SESSIONS_BOTO_SESSION', False) 21 | 22 | # Allow a boto session to be provided, i.e. for auto refreshing credentials 23 | if not _BOTO_SESSION: 24 | AWS_ACCESS_KEY_ID = getattr( 25 | settings, 'DYNAMODB_SESSIONS_AWS_ACCESS_KEY_ID', False) 26 | if not AWS_ACCESS_KEY_ID: 27 | AWS_ACCESS_KEY_ID = getattr( 28 | settings, 'AWS_ACCESS_KEY_ID') 29 | 30 | AWS_SECRET_ACCESS_KEY = getattr( 31 | settings, 'DYNAMODB_SESSIONS_AWS_SECRET_ACCESS_KEY', False) 32 | if not AWS_SECRET_ACCESS_KEY: 33 | AWS_SECRET_ACCESS_KEY = getattr(settings, 'AWS_SECRET_ACCESS_KEY') 34 | 35 | AWS_REGION_NAME = getattr(settings, 'DYNAMODB_SESSIONS_AWS_REGION_NAME', 36 | False) 37 | if not AWS_REGION_NAME: 38 | AWS_REGION_NAME = getattr(settings, 'AWS_REGION_NAME', 'us-east-1') 39 | 40 | # We'll find some better way to do this. 41 | _DYNAMODB_CONN = None 42 | 43 | logger = logging.getLogger(__name__) 44 | 45 | 46 | def dynamodb_connection_factory(): 47 | """ 48 | Since SessionStore is called for every single page view, we'd be 49 | establishing new connections so frequently that performance would be 50 | hugely impacted. We'll lazy-load this here on a per-worker basis. Since 51 | boto3.resource.('dynamodb')objects are state-less (aside from security 52 | tokens), we're not too concerned about thread safety issues. 53 | """ 54 | 55 | global _DYNAMODB_CONN 56 | global _BOTO_SESSION 57 | if not _DYNAMODB_CONN: 58 | logger.debug("Creating a DynamoDB connection.") 59 | if not _BOTO_SESSION: 60 | _BOTO_SESSION = Boto3Session( 61 | aws_access_key_id=AWS_ACCESS_KEY_ID, 62 | aws_secret_access_key=AWS_SECRET_ACCESS_KEY, 63 | region_name=AWS_REGION_NAME) 64 | _DYNAMODB_CONN = _BOTO_SESSION.resource('dynamodb') 65 | return _DYNAMODB_CONN 66 | 67 | 68 | class SessionStore(SessionBase): 69 | """ 70 | Implements DynamoDB session store. 71 | """ 72 | 73 | def __init__(self, session_key=None): 74 | super(SessionStore, self).__init__(session_key) 75 | self._table = None 76 | 77 | @property 78 | def table(self): 79 | if self._table is None: 80 | self._table = dynamodb_connection_factory().Table(TABLE_NAME) 81 | return self._table 82 | 83 | def load(self): 84 | """ 85 | Loads session data from DynamoDB, runs it through the session 86 | data de-coder (base64->dict), sets ``self.session``. 87 | 88 | :rtype: dict 89 | :returns: The de-coded session data, as a dict. 90 | """ 91 | 92 | response = self.table.get_item( 93 | Key={'session_key': self.session_key}, 94 | ConsistentRead=ALWAYS_CONSISTENT) 95 | if 'Item' in response: 96 | session_data = response['Item']['data'] 97 | return self.decode(session_data) 98 | else: 99 | self.create() 100 | return {} 101 | 102 | def exists(self, session_key): 103 | """ 104 | Checks to see if a session currently exists in DynamoDB. 105 | 106 | :rtype: bool 107 | :returns: ``True`` if a session with the given key exists in the DB, 108 | ``False`` if not. 109 | """ 110 | 111 | response = self.table.get_item( 112 | Key={'session_key': session_key}, 113 | ConsistentRead=ALWAYS_CONSISTENT) 114 | if 'Item' in response: 115 | return True 116 | else: 117 | return False 118 | 119 | def create(self): 120 | """ 121 | Creates a new entry in DynamoDB. This may or may not actually 122 | have anything in it. 123 | """ 124 | 125 | while True: 126 | try: 127 | # Save immediately to ensure we have a unique entry in the 128 | # database. 129 | self.save(must_create=True) 130 | except CreateError: 131 | continue 132 | self.modified = True 133 | self._session_cache = {} 134 | return 135 | 136 | def save(self, must_create=False): 137 | """ 138 | Saves the current session data to the database. 139 | 140 | :keyword bool must_create: If ``True``, a ``CreateError`` exception 141 | will be raised if the saving operation doesn't create a *new* entry 142 | (as opposed to possibly updating an existing entry). 143 | :raises: ``CreateError`` if ``must_create`` is ``True`` and a session 144 | with the current session key already exists. 145 | """ 146 | 147 | # If the save method is called with must_create equal to True, I'm 148 | # setting self._session_key equal to None and when 149 | # self.get_or_create_session_key is called the new 150 | # session_key will be created. 151 | if must_create: 152 | self._session_key = None 153 | 154 | self._get_or_create_session_key() 155 | 156 | update_kwargs = { 157 | 'Key': {'session_key': self.session_key}, 158 | } 159 | attribute_names = {'#data': 'data'} 160 | attribute_values = { 161 | ':data': self.encode(self._get_session(no_load=must_create)) 162 | } 163 | set_updates = ['#data = :data'] 164 | if must_create: 165 | # Set condition to ensure session with same key doesnt exist 166 | update_kwargs['ConditionExpression'] = \ 167 | DynamoConditionAttr('session_key').not_exists() 168 | attribute_values[':created'] = int(time.time()) 169 | set_updates.append('created = :created') 170 | update_kwargs['UpdateExpression'] = 'SET ' + ','.join(set_updates) 171 | update_kwargs['ExpressionAttributeValues'] = attribute_values 172 | update_kwargs['ExpressionAttributeNames'] = attribute_names 173 | try: 174 | self.table.update_item(**update_kwargs) 175 | except ClientError as e: 176 | error_code = e.response['Error']['Code'] 177 | if error_code == 'ConditionalCheckFailedException': 178 | raise CreateError 179 | raise 180 | 181 | def delete(self, session_key=None): 182 | """ 183 | Deletes the current session, or the one specified in ``session_key``. 184 | 185 | :keyword str session_key: Optionally, override the session key 186 | to delete. 187 | """ 188 | 189 | if session_key is None: 190 | if self.session_key is None: 191 | return 192 | session_key = self.session_key 193 | 194 | self.table.delete_item(Key={'session_key': session_key}) 195 | --------------------------------------------------------------------------------