From 5d6eefa498cf532bde6e9aed4ed278597a8289bc Mon Sep 17 00:00:00 2001 From: Hugo Brito Date: Wed, 27 Oct 2021 17:38:48 -0300 Subject: [PATCH] Create Horizon session control logic The Multiple Simultaneous Logins Control is a feature designed for securing Horizon dashboard sessions. The default Horizon configuration allows the same user to login several times (e.g. different browsers) simultaneously, that is, the same user can have more than one active session for Horizon dashboard. When there is the need to control the active sessions that one user can have simultaneously, it will be possible to configure the Horizon dashboard to disallow more than one active session per user. When multiple simultaneously sessions are disabled, the most recent authenticated session will be considered the valid one and the previous session will be invalidated. The following manual tests encompass both simulteaneous session control configuration: 'allow' and 'disconnect' and were verified with this code change before submitting it: Test Plan: PASS: Verify that a user is able to login to Horizon dashboard (when configuration is 'disconnect') PASS: Verify that a user is able to start a second Horizon dashboard session and the first session is finished (when configuration is 'disconnect') Failure Path: PASS: Verify that when a user fails to authenticate a second Horizon dashboard session the first session stills active (when configuration is 'disconnect') Regression: PASS: Verify that a user is able to login to Horizon dashboard (when configuration is default: 'allow') PASS: Verify that a user is able to start multiple simultaneous Horizon dashboard sessions (when configuration is default: 'allow') Implements: blueprint handle-multiple-login-sessions-from-same-user-in-horizon Signed-off-by: Hugo Brito Co-authored-by: Thales Elero Cervi Change-Id: I8462aa98398dd8f27fe24d911c9bfaa7f303eb93 --- doc/source/configuration/settings.rst | 16 +++++ horizon/defaults.py | 3 + horizon/middleware/__init__.py | 2 + horizon/middleware/simultaneous_sessions.py | 50 +++++++++++++++ .../middleware/test_simultaneous_sessions.py | 61 +++++++++++++++++++ openstack_dashboard/settings.py | 1 + ...same-user-in-horizon-448baa6534a8a451.yaml | 11 ++++ 7 files changed, 144 insertions(+) create mode 100644 horizon/middleware/simultaneous_sessions.py create mode 100644 horizon/test/unit/middleware/test_simultaneous_sessions.py create mode 100644 releasenotes/notes/bp-handle-multiple-login-sessions-from-same-user-in-horizon-448baa6534a8a451.yaml diff --git a/doc/source/configuration/settings.rst b/doc/source/configuration/settings.rst index a24e5b2870..0d577c1bcb 100644 --- a/doc/source/configuration/settings.rst +++ b/doc/source/configuration/settings.rst @@ -953,6 +953,22 @@ menu and the api access panel. `OPENSTACK_CLOUDS_YAML_CUSTOM_TEMPLATE`_ to provide a custom ``clouds.yaml``. +SIMULTANEOUS_SESSIONS +--------------------- + +.. versionadded:: 21.1.0(Yoga) + +Default: ``allow`` + +Controls whether a user can have multiple simultaneous sessions. +Valid values are ``allow`` and ``disconnect``. + +The value ``allow`` enables more than one simultaneous sessions for a user. +The Value ``disconnect`` disables more than one simultaneous sessions for +a user. Only one active session is allowed. The newer session will be +considered as the valid one and any existing session will be disconnected +after a subsequent successful login. + THEME_COLLECTION_DIR -------------------- diff --git a/horizon/defaults.py b/horizon/defaults.py index eefc438791..6f5d6834f9 100644 --- a/horizon/defaults.py +++ b/horizon/defaults.py @@ -86,6 +86,9 @@ OPERATION_LOG_OPTIONS = { ), } +# Control whether a same user can have multiple action sessions. +SIMULTANEOUS_SESSIONS = 'allow' + OPENSTACK_PROFILER = { 'enabled': False, 'facility_name': 'horizon', diff --git a/horizon/middleware/__init__.py b/horizon/middleware/__init__.py index 6f80672630..1f00a294f0 100644 --- a/horizon/middleware/__init__.py +++ b/horizon/middleware/__init__.py @@ -14,6 +14,8 @@ from horizon.middleware import base from horizon.middleware import operation_log +from horizon.middleware import simultaneous_sessions as sessions HorizonMiddleware = base.HorizonMiddleware OperationLogMiddleware = operation_log.OperationLogMiddleware +SimultaneousSessionsMiddleware = sessions.SimultaneousSessionsMiddleware diff --git a/horizon/middleware/simultaneous_sessions.py b/horizon/middleware/simultaneous_sessions.py new file mode 100644 index 0000000000..5ee217ab4f --- /dev/null +++ b/horizon/middleware/simultaneous_sessions.py @@ -0,0 +1,50 @@ +# Copyright (c) 2021 Wind River Systems Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import importlib +import logging + +from django.conf import settings +from django.core.cache import caches + +LOG = logging.getLogger(__name__) + + +class SimultaneousSessionsMiddleware(object): + def __init__(self, get_response): + self.get_response = get_response + self.simultaneous_sessions = settings.SIMULTANEOUS_SESSIONS + + def __call__(self, request): + self._process_request(request) + response = self.get_response(request) + return response + + def _process_request(self, request): + cache = caches['default'] + cache_key = ('user_pk_{}_restrict').format(request.user.pk) + cache_value = cache.get(cache_key) + if cache_value and self.simultaneous_sessions == 'disconnect': + if request.session.session_key != cache_value: + LOG.info('The user %s is already logged in, ' + 'the last session will be disconnected.', + request.user.id) + engine = importlib.import_module(settings.SESSION_ENGINE) + session = engine.SessionStore(session_key=cache_value) + session.delete() + cache.set(cache_key, request.session.session_key, + settings.SESSION_TIMEOUT) + else: + cache.set(cache_key, request.session.session_key, + settings.SESSION_TIMEOUT) diff --git a/horizon/test/unit/middleware/test_simultaneous_sessions.py b/horizon/test/unit/middleware/test_simultaneous_sessions.py new file mode 100644 index 0000000000..371f54f733 --- /dev/null +++ b/horizon/test/unit/middleware/test_simultaneous_sessions.py @@ -0,0 +1,61 @@ +# Copyright (c) 2021 Wind River Systems Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest import mock + +from django.conf import settings +from django.contrib.sessions.backends import signed_cookies +from django import test as django_test +from django.test.utils import override_settings + +from horizon import middleware +from horizon.test import helpers as test + + +class SimultaneousSessionsMiddlewareTest(django_test.TestCase): + + def setUp(self): + self.url = settings.LOGIN_URL + self.factory = test.RequestFactoryWithMessages() + self.get_response = mock.Mock() + self.request = self.factory.get(self.url) + self.request.user.pk = '123' + super().setUp() + + @mock.patch.object(signed_cookies.SessionStore, 'delete', return_value=None) + def test_simultaneous_sessions(self, mock_delete): + mw = middleware.SimultaneousSessionsMiddleware( + self.get_response) + + self.request.session._set_session_key('123456789') + mw._process_request(self.request) + mock_delete.assert_not_called() + + self.request.session._set_session_key('987654321') + mw._process_request(self.request) + mock_delete.assert_not_called() + + @override_settings(SIMULTANEOUS_SESSIONS='disconnect') + @mock.patch.object(signed_cookies.SessionStore, 'delete', return_value=None) + def test_disconnect_simultaneous_sessions(self, mock_delete): + mw = middleware.SimultaneousSessionsMiddleware( + self.get_response) + + self.request.session._set_session_key('123456789') + mw._process_request(self.request) + mock_delete.assert_not_called() + + self.request.session._set_session_key('987654321') + mw._process_request(self.request) + mock_delete.assert_called_once_with() diff --git a/openstack_dashboard/settings.py b/openstack_dashboard/settings.py index ccfb4bfa9c..79d2812529 100644 --- a/openstack_dashboard/settings.py +++ b/openstack_dashboard/settings.py @@ -82,6 +82,7 @@ MIDDLEWARE = ( 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'horizon.middleware.OperationLogMiddleware', + 'horizon.middleware.SimultaneousSessionsMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'horizon.middleware.HorizonMiddleware', 'horizon.themes.ThemeMiddleware', diff --git a/releasenotes/notes/bp-handle-multiple-login-sessions-from-same-user-in-horizon-448baa6534a8a451.yaml b/releasenotes/notes/bp-handle-multiple-login-sessions-from-same-user-in-horizon-448baa6534a8a451.yaml new file mode 100644 index 0000000000..513e51460d --- /dev/null +++ b/releasenotes/notes/bp-handle-multiple-login-sessions-from-same-user-in-horizon-448baa6534a8a451.yaml @@ -0,0 +1,11 @@ +features: + - | + [:blueprint:`handle-multiple-login-sessions-from-same-user-in-horizon`] + This blueprint allows operators to control if multiple simultaneous + dashboard sessions are allowed or not for a user. A new setting + ``SIMULTANEOUS_SESSIONS`` controls the behavior. The default behavior + allows multiple dashboard sessions for a user. The new setting allows + operators to configure horizon to disallow multiple sessions per user. + When multiple simultaneous sessions are disabled, the most recent + authenticated session will be considered as the valid one and + the previous session will be invalidated.