Change session timeout to an idle timeout value

Add a new config SESSION_REFRESH (default True) which
turns SESSION_TIMEOUT into an idle timeout rather than
a hard timeout.

The existing hard timeout is awful UX, and while
SESSION_TIMEOUT could be set to a higher value, it
still makes for a somewhat unpleasant experience.

Co-Authored-By: Akihiro Motoki <amotoki@gmail.com>
Change-Id: Icc6942e62c4e8d2fac57988b0a2233a8073b1944
This commit is contained in:
Adrian Turjak 2018-07-11 16:33:31 +12:00 committed by Akihiro Motoki
parent 06ab7a5047
commit dc0ffaf2d8
7 changed files with 128 additions and 6 deletions

View File

@ -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. existing theme and not allow that parent theme to be selected by the user.
``SELECTABLE_THEMES`` takes the exact same format as ``AVAILABLE_THEMES``. ``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 SESSION_TIMEOUT
--------------- ---------------
@ -805,9 +815,14 @@ SESSION_TIMEOUT
Default: ``"3600"`` Default: ``"3600"``
This SESSION_TIMEOUT is a method to supercede the token timeout with a shorter This SESSION_TIMEOUT is a method to supercede the token timeout with a
horizon session timeout (in seconds). So if your token expires in 60 minutes, shorter horizon session timeout (in seconds). If SESSION_REFRESH is True (the
a value of 1800 will log users out after 30 minutes. 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 MEMOIZED_MAX_SIZE_DEFAULT
------------------------- -------------------------

View File

@ -19,9 +19,12 @@
Middleware provided and used by Horizon. Middleware provided and used by Horizon.
""" """
import datetime
import json import json
import logging import logging
import pytz
from django.conf import settings from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.views import redirect_to_login from django.contrib.auth.views import redirect_to_login
@ -65,6 +68,15 @@ class HorizonMiddleware(object):
# to avoid creating too many sessions # to avoid creating too many sessions
return None 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 request.is_ajax():
# if the request is Ajax we do not want to proceed, as clients can # if the request is Ajax we do not want to proceed, as clients can
# 1) create pages with constant polling, which can create race # 1) create pages with constant polling, which can create race

View File

@ -13,11 +13,15 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import datetime
import mock import mock
import pytz
from django.conf import settings from django.conf import settings
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django import test as django_test from django import test as django_test
from django.test.utils import override_settings
from django.utils import timezone from django.utils import timezone
from horizon import exceptions from horizon import exceptions
@ -65,11 +69,13 @@ class MiddlewareTests(django_test.TestCase):
self.assertEqual(200, resp.status_code) self.assertEqual(200, resp.status_code)
self.assertEqual(url, resp['X-Horizon-Location']) self.assertEqual(url, resp['X-Horizon-Location'])
@override_settings(SESSION_REFRESH=False)
def test_timezone_awareness(self): def test_timezone_awareness(self):
url = settings.LOGIN_REDIRECT_URL url = settings.LOGIN_REDIRECT_URL
mw = middleware.HorizonMiddleware(self.get_response) mw = middleware.HorizonMiddleware(self.get_response)
request = self.factory.get(url) request = self.factory.get(url)
request.session['django_timezone'] = 'America/Chicago' request.session['django_timezone'] = 'America/Chicago'
mw._process_request(request) mw._process_request(request)
self.assertEqual( self.assertEqual(
@ -80,3 +86,67 @@ class MiddlewareTests(django_test.TestCase):
request.session['django_timezone'] = 'UTC' request.session['django_timezone'] = 'UTC'
mw._process_request(request) mw._process_request(request)
self.assertEqual(timezone.get_current_timezone_name(), 'UTC') 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)

View File

@ -18,6 +18,7 @@ import copy
from django.conf import settings from django.conf import settings
from django import http from django import http
from django.test.utils import override_settings
import six import six
@ -348,6 +349,7 @@ class TabExceptionTests(test.TestCase):
super(TabExceptionTests, self).tearDown() super(TabExceptionTests, self).tearDown()
TabWithTableView.tab_group_class.tabs = self._original_tabs TabWithTableView.tab_group_class.tabs = self._original_tabs
@override_settings(SESSION_REFRESH=False)
def test_tab_view_exception(self): def test_tab_view_exception(self):
TabWithTableView.tab_group_class.tabs.append(RecoverableErrorTab) TabWithTableView.tab_group_class.tabs.append(RecoverableErrorTab)
view = TabWithTableView.as_view() view = TabWithTableView.as_view()
@ -355,6 +357,7 @@ class TabExceptionTests(test.TestCase):
res = view(req) res = view(req)
self.assertMessageCount(res, error=1) self.assertMessageCount(res, error=1)
@override_settings(SESSION_REFRESH=False)
def test_tab_302_exception(self): def test_tab_302_exception(self):
TabWithTableView.tab_group_class.tabs.append(RedirectExceptionTab) TabWithTableView.tab_group_class.tabs.append(RedirectExceptionTab)
view = TabWithTableView.as_view() view = TabWithTableView.as_view()

View File

@ -25,6 +25,7 @@ from six import moves
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.test.utils import override_settings
from django import urls from django import urls
import horizon import horizon
@ -268,6 +269,7 @@ class HorizonTests(BaseHorizonTests):
self.assertEqual(redirect_url, self.assertEqual(redirect_url,
resp["X-Horizon-Location"]) resp["X-Horizon-Location"])
@override_settings(SESSION_REFRESH=False)
def test_required_permissions(self): def test_required_permissions(self):
dash = horizon.get_dashboard("cats") dash = horizon.get_dashboard("cats")
panel = dash.get_panel('tigers') panel = dash.get_panel('tigers')
@ -427,6 +429,7 @@ class CustomPermissionsTests(BaseHorizonTests):
# refresh config # refresh config
conf.HORIZON_CONFIG._setup() conf.HORIZON_CONFIG._setup()
@override_settings(SESSION_REFRESH=False)
def test_customized_permissions(self): def test_customized_permissions(self):
dogs = horizon.get_dashboard("dogs") dogs = horizon.get_dashboard("dogs")
panel = dogs.get_panel('puppies') panel = dogs.get_panel('puppies')

View File

@ -203,9 +203,17 @@ SESSION_COOKIE_HTTPONLY = True
SESSION_EXPIRE_AT_BROWSER_CLOSE = True SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SESSION_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False
# SESSION_TIMEOUT is a method to supersede the token timeout with a shorter # Control whether the SESSION_TIMEOUT period is refreshed due to activity. If
# horizon session timeout (in seconds). So if your token expires in 60 # False, SESSION_TIMEOUT acts as a hard limit.
# minutes, a value of 1800 will log users out after 30 minutes 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 SESSION_TIMEOUT = 3600
# When using cookie-based sessions, log error when the session cookie exceeds # When using cookie-based sessions, log error when the session cookie exceeds

View File

@ -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``.