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 0e4c9176bc..dcef6dd07f 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 93f6f03d00..b192efadf4 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.