diff --git a/.gitignore b/.gitignore index 2345f22..d39143a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__ *.egg-info *.sqlite3 +*.log venv htmlcov build diff --git a/README.md b/README.md index 8cf0d36..f85ef85 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,62 @@ See `TEMPLATES` → `OPTIONS` → `context_processors` in your `settings.py` fil --- +## ✨ Logout a user when all his tabs are closed (experimental) + +If all tabs are closed or if the browser is closed, actually... + +Add to `AUTO_LOGOUT` settings: + +```python +AUTO_LOGOUT['LOGOUT_ON_TABS_CLOSED'] = True +``` + +Also for this option you should add a context processor: + +```python +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + + # ↓↓↓ Add this ↓↓↓ + 'django_auto_logout.context_processors.auto_logout_client', + ], + }, + }, +] +``` + +Add `logout_on_tabs_closed` variable to your template layout: + +``` +{{ logout_on_tabs_closed }} +``` + +It works for almost all browsers on 🖥️: + +- IE ≥ 8 +- Edge ≥ 12 +- Firefox ≥ 3.5 +- Chrome ≥ 4 +- Safari ≥ 4 +- Opera ≥ 11.5 + +And 📱 browsers: + +- iOS Safari ≥ 3.2 +- Android Browser ≥ 94 +- Android Chrome ≥ 94 +- Android Firefox ≥ 92 +- Opera Mobile ≥ 12 + ## 🌈 Combine configurations You can combine previous configurations. For example, you may want to logout a user @@ -115,6 +171,7 @@ from datetime import timedelta AUTO_LOGOUT = { 'IDLE_TIME': timedelta(minutes=5), 'SESSION_TIME': timedelta(minutes=30), + 'LOGOUT_ON_TABS_CLOSED': True, 'MESSAGE': 'The session has expired. Please login again to continue.', } ``` diff --git a/django_auto_logout/context_processors.py b/django_auto_logout/context_processors.py new file mode 100644 index 0000000..eacf977 --- /dev/null +++ b/django_auto_logout/context_processors.py @@ -0,0 +1,59 @@ +from django.conf import settings +from django.utils.safestring import mark_safe + +LOGOUT_URL = settings.AUTO_LOGOUT.get('LOGOUT_URL', '/djal-send-logout/') + + +def trim(s: str) -> str: + return ''.join([line.strip() for line in s.split('\n')]) + + +class LogoutOnTabClosed: + template = trim(f''' + + ''') + + def __init__(self, request): + self._request = request + + def __str__(self): + return mark_safe(self.template) + + +def auto_logout_client(request): + if settings.AUTO_LOGOUT.get('LOGOUT_ON_TABS_CLOSED'): + html = LogoutOnTabClosed(request) + else: + html = '' + + return { + 'logout_on_tabs_closed': html, + 'logout_url': LOGOUT_URL, + } diff --git a/django_auto_logout/middleware.py b/django_auto_logout/middleware.py index 66a958e..018a090 100644 --- a/django_auto_logout/middleware.py +++ b/django_auto_logout/middleware.py @@ -1,25 +1,33 @@ from datetime import datetime, timedelta import logging -from typing import Callable +from typing import Callable, Optional from django.conf import settings from django.http import HttpRequest, HttpResponse from django.contrib.auth import get_user_model, logout from django.contrib.messages import info from pytz import timezone +LOGOUT_URL = settings.AUTO_LOGOUT.get('LOGOUT_URL', '/djal-send-logout/') UserModel = get_user_model() logger = logging.getLogger(__name__) -def _auto_logout(request: HttpRequest, options): +def _auto_logout(request: HttpRequest, options) -> Optional[HttpResponse]: user = request.user should_logout = False + replace_response: Optional[HttpResponse] = None if settings.USE_TZ: now = datetime.now(tz=timezone(settings.TIME_ZONE)) else: now = datetime.now() + if settings.AUTO_LOGOUT.get('LOGOUT_ON_TABS_CLOSED'): + if request.path == LOGOUT_URL and request.method.lower() == 'post': + should_logout |= True + replace_response = HttpResponse() + logger.info('Client %r requested for logout', user) + if options.get('SESSION_TIME') is not None: if isinstance(options['SESSION_TIME'], timedelta): ttl = options['SESSION_TIME'] @@ -64,11 +72,15 @@ def _auto_logout(request: HttpRequest, options): if options.get('MESSAGE') is not None: info(request, options['MESSAGE']) + return replace_response + def auto_logout(get_response: Callable[[HttpRequest], HttpResponse]) -> Callable: def middleware(request: HttpRequest) -> HttpResponse: if not request.user.is_anonymous and hasattr(settings, 'AUTO_LOGOUT'): - _auto_logout(request, settings.AUTO_LOGOUT) + replace_response = _auto_logout(request, settings.AUTO_LOGOUT) + if replace_response: + return replace_response return get_response(request) return middleware diff --git a/example/example/settings.py b/example/example/settings.py index 0c8d71a..17e2533 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -66,6 +66,8 @@ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + + 'django_auto_logout.context_processors.auto_logout_client', ], }, }, @@ -159,11 +161,13 @@ } LOGIN_URL = '/login/' +LOGIN_REDIRECT_URL = '/login-required/' # DJANGO AUTO LOGIN AUTO_LOGOUT = { - 'IDLE_TIME': 10, # 10 seconds - 'SESSION_TIME': 120, # 2 minutes + 'IDLE_TIME': 300, # 5 minutes + 'SESSION_TIME': 1800, # 30 minutes 'MESSAGE': 'The session has expired. Please login again to continue.', + 'LOGOUT_ON_TABS_CLOSED': True, } diff --git a/example/example/urls.py b/example/example/urls.py index b91b78c..86a92a3 100644 --- a/example/example/urls.py +++ b/example/example/urls.py @@ -1,10 +1,10 @@ from django.contrib import admin from django.urls import path -from some_app_login_required.views import login_page, login_required_view +from some_app_login_required.views import UserLoginView, login_required_view urlpatterns = [ path('admin/', admin.site.urls), - path('login/', login_page), + path('login/', UserLoginView.as_view()), path('login-required/', login_required_view), ] diff --git a/example/some_app_login_required/templates/layout.html b/example/some_app_login_required/templates/layout.html index 8583f5c..3cdb8e9 100644 --- a/example/some_app_login_required/templates/layout.html +++ b/example/some_app_login_required/templates/layout.html @@ -2,7 +2,7 @@ - Title + {% block title %}{% endblock %} {% for message in messages %} @@ -11,6 +11,13 @@ {% endfor %} +

+ internal link + external link +

+ {% block content %}{% endblock %} + +{{ logout_on_tabs_closed }} diff --git a/example/some_app_login_required/templates/login_page.html b/example/some_app_login_required/templates/login_page.html index 5c6bef0..4dcee03 100644 --- a/example/some_app_login_required/templates/login_page.html +++ b/example/some_app_login_required/templates/login_page.html @@ -1,7 +1,20 @@ {% extends 'layout.html' %} +{% block title %}login page{% endblock %} + {% block content %} -

- login page -

+
+

login page

+ +
+ {% csrf_token %} + + + {{ form }} + + + +
+
+
{% endblock %} diff --git a/example/some_app_login_required/templates/login_required.html b/example/some_app_login_required/templates/login_required.html index 6bac107..b727caa 100644 --- a/example/some_app_login_required/templates/login_required.html +++ b/example/some_app_login_required/templates/login_required.html @@ -1,5 +1,7 @@ {% extends 'layout.html' %} +{% block title %}login required page{% endblock %} + {% block content %}

login required view diff --git a/example/some_app_login_required/tests.py b/example/some_app_login_required/tests.py index aeadca9..347c3ad 100644 --- a/example/some_app_login_required/tests.py +++ b/example/some_app_login_required/tests.py @@ -1,8 +1,10 @@ -from time import sleep from datetime import timedelta +from time import sleep +from unittest import skipIf from django.test import TestCase from django.conf import settings from django.contrib.auth import get_user_model +from django.contrib.staticfiles.testing import StaticLiveServerTestCase UserModel = get_user_model() @@ -205,3 +207,105 @@ def test_no_messages_if_no_messages(self): resp = self.client.get(resp['location']) self.assertContains(resp, 'login page', msg_prefix=resp.content.decode()) self.assertNotContains(resp, 'class="message info"', msg_prefix=resp.content.decode()) + + +class TestAutoLogoutBrowserScript(TestAutoLogout): + def test_logout_url_ok(self): + settings.AUTO_LOGOUT['LOGOUT_ON_TABS_CLOSED'] = True + self.client.force_login(self.user) + self.assertLoginRequiredIsOk() + self.client.post('/djal-send-logout/') + self.assertLoginRequiredRedirect() + + def test_logout_url_not_ok(self): + settings.AUTO_LOGOUT['LOGOUT_ON_TABS_CLOSED'] = False + self.client.force_login(self.user) + self.assertLoginRequiredIsOk() + self.client.post('/djal-send-logout/') + self.assertLoginRequiredIsOk() + + def test_check_script_inserted_not_ok(self): + settings.AUTO_LOGOUT['LOGOUT_ON_TABS_CLOSED'] = False + resp = self.client.get(settings.LOGIN_URL) + self.assertNotContains(resp, '') + + +try: + from selenium.webdriver.firefox.webdriver import WebDriver + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys +except ImportError: + skip_selenium = True +else: + skip_selenium = True # ToDo: Not ready + + +@skipIf(skip_selenium, 'No selenium') +class TestBrowser(StaticLiveServerTestCase): + browser = None # type: WebDriver + url = '/login-required/' + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.browser = WebDriver() + cls.browser.implicitly_wait(10) + + @classmethod + def tearDownClass(cls): + cls.browser.quit() + super().tearDownClass() + + def setUp(self) -> None: + self.user = UserModel.objects.create_user('user', 'user@localhost', 'pass') + self.superuser = UserModel.objects.create_superuser('superuser', 'superuser@localhost', 'pass') + + def _login_user(self): + self.browser.get(f'{self.live_server_url}{settings.LOGIN_URL}') + username_input = self.browser.find_element(By.CSS_SELECTOR, "input[name=username]") + username_input.send_keys('user') + password_input = self.browser.find_element(By.CSS_SELECTOR, "input[name=password]") + password_input.send_keys('pass') + self.browser.find_element(By.CSS_SELECTOR, 'button[type=submit]').click() + + def test_can_open_browser(self): + self.browser.get(f'{self.live_server_url}{settings.LOGIN_URL}') + self.assertIn('login page', self.browser.title) + + def test_auto_logout_session_time(self): + settings.AUTO_LOGOUT['SESSION_TIME'] = 1 + self._login_user() + sleep(0.5) + self.assertIn('login required page', self.browser.title) + sleep(0.5) + self.browser.get(f'{self.live_server_url}{self.url}') + self.assertIn('login page', self.browser.title) + + def test_logout_on_tab_closed_not_set(self): + settings.AUTO_LOGOUT['LOGOUT_ON_TABS_CLOSED'] = False + self._login_user() + self.assertIn('login required page', self.browser.title) + self.browser.find_element(By.TAG_NAME, 'body').send_keys(Keys.CONTROL + 'w') + self.browser.find_element(By.TAG_NAME, 'body').send_keys(Keys.CONTROL + Keys.SHIFT + 't') + self.browser.get(f'{self.live_server_url}{self.url}') + self.assertIn('login required page', self.browser.title) + + def test_logout_on_tab_closed(self): + settings.AUTO_LOGOUT['LOGOUT_ON_TABS_CLOSED'] = True + self._login_user() + self.assertIn('login required page', self.browser.title) + self.browser.find_element(By.TAG_NAME, 'body').send_keys(Keys.CONTROL + 'w') + sleep(0.5) + self.browser.find_element(By.TAG_NAME, 'body').send_keys(Keys.CONTROL + Keys.SHIFT + 't') + self.browser.get(f'{self.live_server_url}{self.url}') + self.assertIn('login page', self.browser.title) diff --git a/example/some_app_login_required/views.py b/example/some_app_login_required/views.py index 4f449e3..01c5dbb 100644 --- a/example/some_app_login_required/views.py +++ b/example/some_app_login_required/views.py @@ -1,9 +1,12 @@ from django.contrib.auth.decorators import login_required from django.shortcuts import render +from django.contrib.auth.views import LoginView +from django.contrib.auth.forms import AuthenticationForm -def login_page(request): - return render(request, 'login_page.html', {}) +class UserLoginView(LoginView): + form_class = AuthenticationForm + template_name = 'login_page.html' @login_required diff --git a/requirements-dev.txt b/requirements-dev.txt index d090dab..0b80160 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,3 +2,4 @@ build>=0.7.0 coverage>=6.0.2 tox>=3.24.4 +# selenium>=4.0.0 diff --git a/tox.ini b/tox.ini index 9e33177..475e424 100644 --- a/tox.ini +++ b/tox.ini @@ -3,10 +3,11 @@ skipsdist = True envlist = py37, py38, py39, py310 [testenv] -deps= -r{toxinidir}/requirements-dev.txt +deps = -r{toxinidir}/requirements-dev.txt commands = coverage run ./runtests.py - coverage html --fail-under=100 + coverage report --omit=example/some_app_login_required/tests.py + coverage html --omit=example/some_app_login_required/tests.py --fail-under=100 setenv = LANG = ru_RU.UTF-8 PYTHONPATH = {toxinidir}