diff --git a/doc/source/configuration/settings.rst b/doc/source/configuration/settings.rst index 7e28a76608..253e4f857b 100644 --- a/doc/source/configuration/settings.rst +++ b/doc/source/configuration/settings.rst @@ -798,6 +798,16 @@ in `AVAILABLE_THEMES`_, but a brander may wish to simply inherit from an existing theme and not allow that parent theme to be selected by the user. ``SELECTABLE_THEMES`` takes the exact same format as ``AVAILABLE_THEMES``. +SESSION_REFRESH +--------------- + +.. versionadded:: 15.0.0(Stein) + +Default: ``True`` + +Control whether the SESSION_TIMEOUT period is refreshed due to activity. If +False, SESSION_TIMEOUT acts as a hard limit. + SESSION_TIMEOUT --------------- @@ -805,9 +815,14 @@ SESSION_TIMEOUT Default: ``"3600"`` -This SESSION_TIMEOUT is a method to supercede the token timeout with a shorter -horizon session timeout (in seconds). So if your token expires in 60 minutes, -a value of 1800 will log users out after 30 minutes. +This SESSION_TIMEOUT is a method to supercede the token timeout with a +shorter horizon session timeout (in seconds). If SESSION_REFRESH is True (the +default) SESSION_TIMEOUT acts like an idle timeout rather than being a hard +limit, but will never exceed the token expiry. If your token expires in 60 +minutes, a value of 1800 will log users out after 30 minutes of inactivity, +or 60 minutes with activity. Setting SESSION_REFRESH to False will make +SESSION_TIMEOUT act like a hard limit on session times. + MEMOIZED_MAX_SIZE_DEFAULT ------------------------- diff --git a/horizon/middleware/base.py b/horizon/middleware/base.py index ebae6d3a11..91c0e757a1 100644 --- a/horizon/middleware/base.py +++ b/horizon/middleware/base.py @@ -19,9 +19,12 @@ Middleware provided and used by Horizon. """ +import datetime import json import logging +import pytz + from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.views import redirect_to_login @@ -65,6 +68,15 @@ class HorizonMiddleware(object): # to avoid creating too many sessions return None + # Since we know the user is present and authenticated, lets refresh the + # session expiry if configured to do so. + if getattr(settings, "SESSION_REFRESH", True): + timeout = getattr(settings, "SESSION_TIMEOUT", 3600) + token_life = request.user.token.expires - datetime.datetime.now( + pytz.utc) + session_time = min(timeout, int(token_life.total_seconds())) + request.session.set_expiry(session_time) + if request.is_ajax(): # if the request is Ajax we do not want to proceed, as clients can # 1) create pages with constant polling, which can create race diff --git a/horizon/test/unit/middleware/test_base.py b/horizon/test/unit/middleware/test_base.py index 142543bf55..01f75ec82b 100644 --- a/horizon/test/unit/middleware/test_base.py +++ b/horizon/test/unit/middleware/test_base.py @@ -13,11 +13,15 @@ # License for the specific language governing permissions and limitations # under the License. +import datetime + import mock +import pytz from django.conf import settings from django.http import HttpResponseRedirect from django import test as django_test +from django.test.utils import override_settings from django.utils import timezone from horizon import exceptions @@ -65,11 +69,13 @@ class MiddlewareTests(django_test.TestCase): self.assertEqual(200, resp.status_code) self.assertEqual(url, resp['X-Horizon-Location']) + @override_settings(SESSION_REFRESH=False) def test_timezone_awareness(self): url = settings.LOGIN_REDIRECT_URL mw = middleware.HorizonMiddleware(self.get_response) request = self.factory.get(url) + request.session['django_timezone'] = 'America/Chicago' mw._process_request(request) self.assertEqual( @@ -80,3 +86,67 @@ class MiddlewareTests(django_test.TestCase): request.session['django_timezone'] = 'UTC' mw._process_request(request) self.assertEqual(timezone.get_current_timezone_name(), 'UTC') + + @override_settings(SESSION_TIMEOUT=600, + SESSION_REFRESH=True) + def test_refresh_session_expiry_enough_token_life(self): + url = settings.LOGIN_REDIRECT_URL + mw = middleware.HorizonMiddleware(self.get_response) + + request = self.factory.get(url) + + now = datetime.datetime.now(pytz.utc) + token_expiry = now + datetime.timedelta(seconds=1800) + request.user.token = mock.Mock(expires=token_expiry) + session_expiry_before = now + datetime.timedelta(seconds=300) + request.session.set_expiry(session_expiry_before) + + mw._process_request(request) + + session_expiry_after = request.session.get_expiry_date() + # Check if session_expiry has been updated. + self.assertGreater(session_expiry_after, session_expiry_before) + # Check session_expiry is before token expiry + self.assertLess(session_expiry_after, token_expiry) + + @override_settings(SESSION_TIMEOUT=600, + SESSION_REFRESH=True) + def test_refresh_session_expiry_near_token_expiry(self): + url = settings.LOGIN_REDIRECT_URL + mw = middleware.HorizonMiddleware(self.get_response) + + request = self.factory.get(url) + + now = datetime.datetime.now(pytz.utc) + token_expiry = now + datetime.timedelta(seconds=10) + request.user.token = mock.Mock(expires=token_expiry) + + mw._process_request(request) + + session_expiry_after = request.session.get_expiry_date() + # Check if session_expiry_after is around token_expiry. + # We set some margin to avoid accidental test failure. + self.assertGreater(session_expiry_after, + token_expiry - datetime.timedelta(seconds=3)) + self.assertLess(session_expiry_after, + token_expiry + datetime.timedelta(seconds=3)) + + @override_settings(SESSION_TIMEOUT=600, + SESSION_REFRESH=False) + def test_no_refresh_session_expiry(self): + url = settings.LOGIN_REDIRECT_URL + mw = middleware.HorizonMiddleware(self.get_response) + + request = self.factory.get(url) + + now = datetime.datetime.now(pytz.utc) + token_expiry = now + datetime.timedelta(seconds=1800) + request.user.token = mock.Mock(expires=token_expiry) + session_expiry_before = now + datetime.timedelta(seconds=300) + request.session.set_expiry(session_expiry_before) + + mw._process_request(request) + + session_expiry_after = request.session.get_expiry_date() + # Check if session_expiry has been updated. + self.assertEqual(session_expiry_after, session_expiry_before) diff --git a/horizon/test/unit/tabs/test_tabs.py b/horizon/test/unit/tabs/test_tabs.py index 3ad4aebdad..6c50e401ba 100644 --- a/horizon/test/unit/tabs/test_tabs.py +++ b/horizon/test/unit/tabs/test_tabs.py @@ -18,6 +18,7 @@ import copy from django.conf import settings from django import http +from django.test.utils import override_settings import six @@ -348,6 +349,7 @@ class TabExceptionTests(test.TestCase): super(TabExceptionTests, self).tearDown() TabWithTableView.tab_group_class.tabs = self._original_tabs + @override_settings(SESSION_REFRESH=False) def test_tab_view_exception(self): TabWithTableView.tab_group_class.tabs.append(RecoverableErrorTab) view = TabWithTableView.as_view() @@ -355,6 +357,7 @@ class TabExceptionTests(test.TestCase): res = view(req) self.assertMessageCount(res, error=1) + @override_settings(SESSION_REFRESH=False) def test_tab_302_exception(self): TabWithTableView.tab_group_class.tabs.append(RedirectExceptionTab) view = TabWithTableView.as_view() diff --git a/horizon/test/unit/test_base.py b/horizon/test/unit/test_base.py index 9929f713f4..5b0bffd870 100644 --- a/horizon/test/unit/test_base.py +++ b/horizon/test/unit/test_base.py @@ -25,6 +25,7 @@ from six import moves from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ImproperlyConfigured +from django.test.utils import override_settings from django import urls import horizon @@ -268,6 +269,7 @@ class HorizonTests(BaseHorizonTests): self.assertEqual(redirect_url, resp["X-Horizon-Location"]) + @override_settings(SESSION_REFRESH=False) def test_required_permissions(self): dash = horizon.get_dashboard("cats") panel = dash.get_panel('tigers') @@ -427,6 +429,7 @@ class CustomPermissionsTests(BaseHorizonTests): # refresh config conf.HORIZON_CONFIG._setup() + @override_settings(SESSION_REFRESH=False) def test_customized_permissions(self): dogs = horizon.get_dashboard("dogs") panel = dogs.get_panel('puppies') diff --git a/openstack_dashboard/settings.py b/openstack_dashboard/settings.py index 2619acd851..b168ae41fc 100644 --- a/openstack_dashboard/settings.py +++ b/openstack_dashboard/settings.py @@ -203,9 +203,17 @@ SESSION_COOKIE_HTTPONLY = True SESSION_EXPIRE_AT_BROWSER_CLOSE = True SESSION_COOKIE_SECURE = False -# SESSION_TIMEOUT is a method to supersede the token timeout with a shorter -# horizon session timeout (in seconds). So if your token expires in 60 -# minutes, a value of 1800 will log users out after 30 minutes +# Control whether the SESSION_TIMEOUT period is refreshed due to activity. If +# False, SESSION_TIMEOUT acts as a hard limit. +SESSION_REFRESH = True + +# This SESSION_TIMEOUT is a method to supercede the token timeout with a +# shorter horizon session timeout (in seconds). If SESSION_REFRESH is True (the +# default) SESSION_TIMEOUT acts like an idle timeout rather than being a hard +# limit, but will never exceed the token expiry. If your token expires in 60 +# minutes, a value of 1800 will log users out after 30 minutes of inactivity, +# or 60 minutes with activity. Setting SESSION_REFRESH to False will make +# SESSION_TIMEOUT act like a hard limit on session times. SESSION_TIMEOUT = 3600 # When using cookie-based sessions, log error when the session cookie exceeds diff --git a/releasenotes/notes/idle-session-timeout-ab47085807881afe.yaml b/releasenotes/notes/idle-session-timeout-ab47085807881afe.yaml new file mode 100644 index 0000000000..ac7bc0a1f8 --- /dev/null +++ b/releasenotes/notes/idle-session-timeout-ab47085807881afe.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + New setting ``SESSION_REFRESH`` (defaults to ``True``) that allows the user + session expiry to be refreshed for every request until the token itself + expires. ``SESSION_TIMEOUT`` acts as an idle timeout value now. +upgrade: + - | + ``SESSION_TIMEOUT`` now by default acts as an idle timeout rather than a + hard timeout limit. If you wish to retain the old hard timeout + functionality set ``SESSION_REFRESH`` to ``False``.