├── tests ├── __init__.py ├── test_django_expiry.py └── settings.py ├── expiry ├── __init__.py ├── apps.py ├── signals.py ├── middleware.py └── rules.py ├── .gitignore ├── tox.ini ├── AUTHORS ├── CONTRIBUTING.md ├── pyproject.toml ├── manage.py ├── .circleci └── config.yml ├── pyproject.lock ├── LICENSE ├── setup.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /expiry/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'expiry.apps.ExpiryConfig' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *.swp 3 | .python-version 4 | build 5 | dist 6 | *.egg-info 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py35, py36 3 | 4 | [testenv] 5 | deps = poetry 6 | commands = 7 | poetry install -v 8 | poetry run ./manage.py test -------------------------------------------------------------------------------- /expiry/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ExpiryConfig(AppConfig): 5 | name = 'expiry' 6 | 7 | def ready(self): 8 | import expiry.signals # noqa 9 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | List of much-appreciated contributors: 2 | 3 | Ahmed Amin 4 | Frederic Tschannen 5 | Rob Jauquet 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to django-expiry 2 | 3 | As an open source project, django-expiry welcomes contributions of many forms. 4 | 5 | Examples of contributions include: 6 | * Code patches 7 | * Documentation improvements 8 | * Bug reports and patch reviews -------------------------------------------------------------------------------- /expiry/signals.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.signals import user_logged_in 2 | from django.dispatch import receiver 3 | 4 | from .rules import process_rules 5 | 6 | 7 | @receiver(user_logged_in) 8 | def logged_expiry_handler(sender, user, request, **kwargs): 9 | """Processes rules for logged in user.""" 10 | process_rules(request=request, user=user) 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-expiry" 3 | version = "0.2.2" 4 | description = "Expiry rules for Django sessions." 5 | authors = ["Ramon Saraiva "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 10 | 11 | Django = ">=2.1.2" 12 | 13 | [tool.poetry.dev-dependencies] 14 | 15 | -------------------------------------------------------------------------------- /tests/test_django_expiry.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from django.test import TestCase 4 | 5 | from expiry.middleware import ExpirySessionMiddleware 6 | 7 | class ExpiryMiddlewareTests(TestCase): 8 | 9 | def setUp(self): 10 | self.middleware = ExpirySessionMiddleware() 11 | self.request = Mock() 12 | self.response = Mock() 13 | 14 | def test_expiry_middleware(self): 15 | response = self.middleware.process_response(self.request, self.response) 16 | self.assertEqual(self.request.session.get_expiry_age.call_count, 1) 17 | 18 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError as exc: 11 | raise ImportError( 12 | "Couldn't import Django. Are you sure it's installed and " 13 | "available on your PYTHONPATH environment variable? Did you " 14 | "forget to activate a virtual environment?" 15 | ) from exc 16 | execute_from_command_line(sys.argv) 17 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | SECRET_KEY = 'dummy' 4 | 5 | INSTALLED_APPS = [ 6 | 'django.contrib.auth', 7 | 'django.contrib.contenttypes', 8 | 'django.contrib.admin', 9 | 'django.contrib.sessions', 10 | 'django.contrib.staticfiles', 11 | 'expiry', 12 | 'tests', 13 | ] 14 | 15 | MIDDLEWARE_CLASSES = ( 16 | 'django.contrib.sessions.middleware.SessionMiddleware', 17 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 18 | 'django.contrib.messages.middleware.MessageMiddleware', 19 | ) 20 | 21 | DATABASES = { 22 | 'default': { 23 | 'ENGINE': 'django.db.backends.sqlite3', 24 | 'NAME': 'django_expiry_test', 25 | } 26 | } 27 | 28 | DEBUG = True 29 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Python CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-python/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers` 11 | - image: circleci/python:3.6.1 12 | 13 | # Specify service dependencies here if necessary 14 | # CircleCI maintains a library of pre-built images 15 | # documented at https://circleci.com/docs/2.0/circleci-images/ 16 | # - image: circleci/postgres:9.4 17 | 18 | working_directory: ~/repo 19 | 20 | steps: 21 | - checkout 22 | - run: 23 | command: | 24 | mkdir -p ./test_reports 25 | mkdir -p ./artifacts 26 | - run: pip install tox && tox 27 | 28 | - store_artifacts: 29 | path: ./artifacts 30 | - store_test_results: 31 | path: ./test_reports 32 | -------------------------------------------------------------------------------- /pyproject.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | category = "main" 3 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 4 | name = "django" 5 | optional = false 6 | platform = "*" 7 | python-versions = ">=3.5" 8 | version = "2.1.2" 9 | 10 | [package.dependencies] 11 | pytz = "*" 12 | 13 | [[package]] 14 | category = "main" 15 | description = "World timezone definitions, modern and historical" 16 | name = "pytz" 17 | optional = false 18 | platform = "Independent" 19 | python-versions = "*" 20 | version = "2018.5" 21 | 22 | [metadata] 23 | content-hash = "edd900a438529cb9986246fe9a454a8af371e740ed745169c0833328eb388cae" 24 | platform = "*" 25 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 26 | 27 | [metadata.hashes] 28 | django = ["acdcc1f61fdb0a0c82a1d3bf1879a414e7732ea894a7632af7f6d66ec7ab5bb3", "efbcad7ebb47daafbcead109b38a5bd519a3c3cd92c6ed0f691ff97fcdd16b45"] 29 | pytz = ["a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053", "ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277"] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ramon Castilhos Saraiva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /expiry/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | try: 4 | from django.utils.deprecation import MiddlewareMixin 5 | except ImportError: 6 | class MiddlewareMixin: 7 | pass 8 | 9 | from .rules import ( 10 | get_settings_key, 11 | process_rules, 12 | ) 13 | 14 | 15 | class ExpirySessionMiddleware(MiddlewareMixin): 16 | 17 | def process_response(self, request, response): 18 | """ 19 | If the current session is fresh (was just created by the default 20 | session middleware, setting its expiry to `SESSION_COOKIE_AGE`) 21 | or the session is configured to keep alive, processes all rules 22 | to identify what `expiry` should be set. 23 | """ 24 | if not (hasattr(request, 'user') and hasattr(request, 'session')): 25 | return response 26 | 27 | key = get_settings_key(request.user) 28 | 29 | fresh_session = ( 30 | request.session.get_expiry_age() == settings.SESSION_COOKIE_AGE 31 | ) 32 | keep_alive = getattr( 33 | settings, 'EXPIRY_{}_KEEP_ALIVE'.format(key), False 34 | ) 35 | 36 | if fresh_session or keep_alive: 37 | process_rules(request=request, user=request.user) 38 | 39 | return response 40 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import ( 4 | find_packages, 5 | setup, 6 | ) 7 | 8 | 9 | def read(fname): 10 | with open(os.path.join(os.path.dirname(__file__), fname)) as f: 11 | return f.read() 12 | 13 | 14 | setup( 15 | name='django-expiry', 16 | version='0.2.3', 17 | license='MIT', 18 | description='Expiry rules for Django sessions.', 19 | long_description=read('README.md'), 20 | long_description_content_type='text/markdown', 21 | author='Ramon Saraiva', 22 | author_email='ramonsaraiva@gmail.com', 23 | url='https://github.com/ramonsaraiva/django-expiry', 24 | packages=find_packages(exclude=['tests*']), 25 | include_package_data=True, 26 | install_requires=[], 27 | python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", 28 | zip_safe=False, 29 | classifiers=[ 30 | 'Development Status :: 4 - Beta', 31 | 'Environment :: Web Environment', 32 | 'Framework :: Django', 33 | 'Intended Audience :: Developers', 34 | 'Operating System :: OS Independent', 35 | 'Programming Language :: Python', 36 | 'Programming Language :: Python :: 2', 37 | 'Programming Language :: Python :: 2.7', 38 | 'Programming Language :: Python :: 3', 39 | 'Programming Language :: Python :: 3.4', 40 | 'Programming Language :: Python :: 3.5', 41 | 'Programming Language :: Python :: 3.6', 42 | 'Topic :: Software Development :: Libraries :: Python Modules', 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /expiry/rules.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.module_loading import import_string 3 | 4 | 5 | def get_settings_key(user): 6 | return 'AUTH' if user.is_authenticated else 'ANON' 7 | 8 | 9 | def process_rule(rule, **kwargs): 10 | """ 11 | Processes a rule. Returns `True` if the rule is valid. 12 | 13 | When the rule is not a callable, tries importing it, assuming it is 14 | a function defined in a specific module of the application. 15 | 16 | Anonymous rules don't contain an user, so we extract it when calling the 17 | rule for validation. 18 | """ 19 | if not callable(rule): 20 | rule = import_string(rule) 21 | 22 | request = kwargs.pop('request') 23 | user = kwargs.pop('user', None) 24 | return rule(request, user) if user.is_authenticated else rule(request) 25 | 26 | 27 | def process_rules(**kwargs): 28 | """ 29 | Processes all rules. If a default age is defined, sets the session 30 | expiry to the default age value. 31 | 32 | Rules will always override the default age. 33 | """ 34 | request = kwargs.get('request') 35 | key = get_settings_key(kwargs['user']) 36 | 37 | default_age = getattr(settings, 'EXPIRY_{}_SESSION_AGE'.format(key), None) 38 | if default_age: 39 | request.session.set_expiry(default_age) 40 | 41 | rules = getattr(settings, 'EXPIRY_{}_SESSION_RULES'.format(key), ()) 42 | for rule, expiry in rules: 43 | if process_rule(rule, **kwargs): 44 | request.session.set_expiry(expiry) 45 | break 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-expiry 2 | 3 | [![CircleCI](https://circleci.com/gh/ramonsaraiva/django-expiry.svg?style=svg)](https://circleci.com/gh/ramonsaraiva/django-expiry) 4 | 5 | Expiry rules for Django sessions. 6 | 7 | ## Installation 8 | 9 | Install using `pip` 10 | 11 | pip install django-expiry 12 | 13 | or `Pipenv` 14 | 15 | pipenv install django-expiry 16 | 17 | Add `expiry` to your `INSTALLED_APPS` setting 18 | 19 | INSTALLED_APPS = ( 20 | ... 21 | 'expiry', 22 | ) 23 | 24 | Add `expiry.middleware.ExpirySessionMiddleware` to your middleware setting 25 | 26 | MIDDLEWARE = ( 27 | ... 28 | 'expiry.middleware.ExpirySessionMiddleware', 29 | ) 30 | 31 | or to middleware classes if your Django is <= 1.9 32 | 33 | MIDDLEWARE_CLASSES = ( 34 | ... 35 | 'expiry.middleware.ExpirySessionMiddleware', 36 | ) 37 | 38 | The middleware will process rules and default ages for fresh sessions. 39 | 40 | ## Usage 41 | 42 | ### Ages 43 | 44 | Default ages can be set for anonymous and authenticated users. When not set, the session age behaviour will default to Django. 45 | 46 | `EXPIRY_ANON_SESSION_AGE` 47 | Default: not set. 48 | 49 | The default age of an anonymous session, in seconds. 50 | 51 | `EXPIRY_ANON_KEEP_ALIVE` 52 | Default: `False` 53 | 54 | Keeps the authenticated session alive, refreshing its expiry for every request, according to its default value and rules. 55 | 56 | `EXPIRY_AUTH_SESSION_AGE` 57 | Default: not set. 58 | 59 | The default age of an authenticated session, in seconds. 60 | 61 | `EXPIRY_AUTH_KEEP_ALIVE` 62 | Default: `False` 63 | 64 | Keeps the anonymous session alive, refreshing its expiry for every request, according to its default value and rules. 65 | 66 | ### Rules 67 | 68 | A set of rules should be defined in your settings file. 69 | You can have rules for anonymous users and authenticated users, handled separately. 70 | 71 | #### Expiry rules for authenticated users only 72 | 73 | Processed whenever an user logs in. Its callable should always accept an `user` and a `request` object. 74 | 75 | EXPIRY_AUTH_SESSION_RULES = ( 76 | (lambda request, user: user.is_staff, 300), 77 | (lambda request, user: user.is_superuser, datetime.timedelta(weeks=2)), 78 | (lambda request, user: user.has_perms('hero'), 99999999), 79 | ) 80 | 81 | #### Expiry rules for anonymous users only 82 | 83 | Processed whenever a session is fresh. Rules are triggered in `ExpirySessionMiddleware`. 84 | 85 | EXPIRY_ANON_SESSION_RULES = ( 86 | (lambda request: request.META.get('REMOTE_ADDR') == '192.168.0.1', 999) 87 | ) 88 | 89 | #### Rule composition 90 | 91 | A rule is a tuple composed by: 92 | * A callable or the path to a callable that will validate it 93 | * An expiry (seconds, datetime, timedelta) 94 | 95 | Note that, for `datetime` and `timedelta` expiries, serialization won't work unless you are using the `PickleSerializer`. 96 | Read more about it [here](https://docs.djangoproject.com/en/2.1/topics/http/sessions/#django.contrib.sessions.backends.base.SessionBase.set_expiry). 97 | 98 | In the examples above, all rules are lambdas, but you can also send the path to a function that will validate it. 99 | 100 | EXPIRY_AUTH_SESSION_RULES = ( 101 | ('app.module.complex_rule', datetime.timedelta(days=64)), 102 | ) 103 | 104 | Then define the rule in that specific module: 105 | 106 | def complex_rule(user, request): 107 | ... 108 | --------------------------------------------------------------------------------