From 814938beb187e8a2b1a2a63f01ac20e77b09a4df Mon Sep 17 00:00:00 2001 From: Volker Theile Date: Sat, 27 Jan 2018 11:55:29 +0100 Subject: [PATCH] mgr/dashboard_v2: Session expire at browser close Add support for session expire at browser close. Signed-off-by: Volker Theile --- .../mgr/dashboard_v2/controllers/auth.py | 30 ++++---- .../app/core/auth/login/login.component.html | 11 +++ .../app/shared/models/credentials.model.ts | 1 + .../frontend/src/openattic-theme.scss | 69 +------------------ .../mgr/dashboard_v2/frontend/tslint.json | 6 +- src/pybind/mgr/dashboard_v2/module.py | 16 +++-- .../mgr/dashboard_v2/tests/test_auth.py | 51 +++++++++++--- src/pybind/mgr/dashboard_v2/tools.py | 54 +++++++++++++-- 8 files changed, 134 insertions(+), 104 deletions(-) diff --git a/src/pybind/mgr/dashboard_v2/controllers/auth.py b/src/pybind/mgr/dashboard_v2/controllers/auth.py index 246314f6d3d..5d28592a40c 100644 --- a/src/pybind/mgr/dashboard_v2/controllers/auth.py +++ b/src/pybind/mgr/dashboard_v2/controllers/auth.py @@ -7,7 +7,7 @@ import sys import bcrypt import cherrypy -from ..tools import ApiController, RESTController +from ..tools import ApiController, RESTController, Session @ApiController('auth') @@ -25,13 +25,8 @@ class Auth(RESTController): | | seconds without activity | """ - SESSION_KEY = '_username' - SESSION_KEY_TS = '_username_ts' - - DEFAULT_SESSION_EXPIRE = 1200.0 - @RESTController.args_from_json - def create(self, username, password): + def create(self, username, password, stay_signed_in=False): now = time.time() config_username = self.mgr.get_localized_config('username', None) config_password = self.mgr.get_localized_config('password', None) @@ -39,8 +34,9 @@ class Auth(RESTController): config_password) if username == config_username and hash_password == config_password: cherrypy.session.regenerate() - cherrypy.session[Auth.SESSION_KEY] = username - cherrypy.session[Auth.SESSION_KEY_TS] = now + cherrypy.session[Session.USERNAME] = username + cherrypy.session[Session.TS] = now + cherrypy.session[Session.EXPIRE_AT_BROWSER_CLOSE] = not stay_signed_in self.logger.debug('Login successful') return {'username': username} @@ -50,8 +46,8 @@ class Auth(RESTController): def bulk_delete(self): self.logger.debug('Logout successful') - cherrypy.session[Auth.SESSION_KEY] = None - cherrypy.session[Auth.SESSION_KEY_TS] = None + cherrypy.session[Session.USERNAME] = None + cherrypy.session[Session.TS] = None @staticmethod def password_hash(password, salt_password=None): @@ -63,7 +59,7 @@ class Auth(RESTController): @staticmethod def check_auth(): - username = cherrypy.session.get(Auth.SESSION_KEY) + username = cherrypy.session.get(Session.USERNAME) if not username: Auth.logger.debug('Unauthorized access to {}'.format(cherrypy.url( relative='server'))) @@ -71,17 +67,17 @@ class Auth(RESTController): 'that resource') now = time.time() expires = float(Auth.mgr.get_localized_config( - 'session-expire', Auth.DEFAULT_SESSION_EXPIRE)) + 'session-expire', Session.DEFAULT_EXPIRE)) if expires > 0: - username_ts = cherrypy.session.get(Auth.SESSION_KEY_TS, None) + username_ts = cherrypy.session.get(Session.TS, None) if username_ts and float(username_ts) < (now - expires): - cherrypy.session[Auth.SESSION_KEY] = None - cherrypy.session[Auth.SESSION_KEY_TS] = None + cherrypy.session[Session.USERNAME] = None + cherrypy.session[Session.TS] = None Auth.logger.debug('Session expired') raise cherrypy.HTTPError(401, 'Session expired. You are not ' 'authorized to access that resource') - cherrypy.session[Auth.SESSION_KEY_TS] = now + cherrypy.session[Session.TS] = now @staticmethod def set_login_credentials(username, password): diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/core/auth/login/login.component.html b/src/pybind/mgr/dashboard_v2/frontend/src/app/core/auth/login/login.component.html index 09241b8b26f..afbabe1b130 100644 --- a/src/pybind/mgr/dashboard_v2/frontend/src/app/core/auth/login/login.component.html +++ b/src/pybind/mgr/dashboard_v2/frontend/src/app/core/auth/login/login.component.html @@ -42,6 +42,17 @@ *ngIf="(loginForm.submitted || password.dirty) && password.invalid">Password is required + +
+ +
+ a { - color: #ececec; - line-height: 1; - padding: 10px 20px; - position: relative; - display: block; - text-decoration: none; -} -.login .utility-bar>a:focus, -.login .utility-bar>a:hover { - color: #ffffff; -} -.login .utility-bar>a:hover { - background-color: #505050; -} -.login .open>a, -.login .open>a:hover, -.login .open>a:focus { - color: #ececec; - border-color: transparent; - background-color: transparent; -} -.login .utility-bar .dropdown-menu { - left: auto; - right: 0; -} -.login .icon-bar { - position: absolute; - margin: 0; - padding: 0; - bottom: 10px; - left: 10px; -} -.login .icon-bar>a { - color: #ececec; - text-decoration: none; -} -.login .icon-bar>a .fa-dark { - color: #474544; -} .login .row { color: #ececec; background-color: #474544; @@ -848,30 +801,12 @@ h6{ margin-top: 0; margin-bottom: 30px; } -.login .btn-password, .login .form-control { color: #ececec; background-color: #555555; } -.login .col-lg-1, .login .col-lg-10, .login .col-lg-11, .login .col-lg-12, .login .col-lg-2, .login .col-lg-3, -.login .col-lg-4, .login .col-lg-5, .login .col-lg-6, .login .col-lg-7, .login .col-lg-8, .login .col-lg-9 { - padding-left: 35px; - padding-right: 35px; -} -.login .col-md-1, .login .col-md-10, .login .col-md-11, .login .col-md-12, .login .col-md-2, .login .col-md-3, -.login .col-md-4, .login .col-md-5, .login .col-md-6, .login .col-md-7, .login .col-md-8, .login .col-md-9 { - padding-left: 30px; - padding-right: 30px; -} -.login .col-sm-1, .login .col-sm-10, .login .col-sm-11, .login .col-sm-12, .login .col-sm-2, .login .col-sm-3, -.login .col-sm-4, .login .col-sm-5, .login .col-sm-6, .login .col-sm-7, .login .col-sm-8, .login .col-sm-9 { - padding-left: 25px; - padding-right: 25px; -} -.login .col-xs-1, .col-xs-10, .login .col-xs-11, .login .col-xs-12, .login .col-xs-2, .login .col-xs-3, -.login .col-xs-4, .login .col-xs-5, .login .col-xs-6, .login .col-xs-7, .login .col-xs-8, .login .col-xs-9 { - padding-left: 15px; - padding-right: 15px; +.login .checkbox { + line-height: 20px; } /* Statistics */ diff --git a/src/pybind/mgr/dashboard_v2/frontend/tslint.json b/src/pybind/mgr/dashboard_v2/frontend/tslint.json index e90833d81e5..341e8f9cd5e 100644 --- a/src/pybind/mgr/dashboard_v2/frontend/tslint.json +++ b/src/pybind/mgr/dashboard_v2/frontend/tslint.json @@ -106,7 +106,11 @@ ], "typeof-compare": true, "unified-signatures": true, - "variable-name": true, + "variable-name": [ + true, + "check-format", + "allow-snake-case" + ], "whitespace": [ true, "check-branch", diff --git a/src/pybind/mgr/dashboard_v2/module.py b/src/pybind/mgr/dashboard_v2/module.py index acb5d0c282d..0c574578d39 100644 --- a/src/pybind/mgr/dashboard_v2/module.py +++ b/src/pybind/mgr/dashboard_v2/module.py @@ -11,7 +11,7 @@ import cherrypy from mgr_module import MgrModule from .controllers.auth import Auth -from .tools import load_controllers, json_error_page +from .tools import load_controllers, json_error_page, SessionExpireAtBrowserCloseTool # cherrypy likes to sys.exit on error. don't let it take us down too! @@ -50,15 +50,21 @@ class Module(MgrModule): self.log.info('server_addr: %s server_port: %s', server_addr, server_port) + # Initialize custom handlers. + cherrypy.tools.authenticate = cherrypy.Tool('before_handler', Auth.check_auth) + cherrypy.tools.session_expire_at_browser_close = SessionExpireAtBrowserCloseTool() + + # Apply the 'global' CherryPy configuration. + config = { + 'engine.autoreload.on': False + } if not in_unittest: - cherrypy.config.update({ + config.update({ 'server.socket_host': server_addr, 'server.socket_port': int(server_port), - 'engine.autoreload.on': False, 'error_page.default': json_error_page }) - cherrypy.tools.authenticate = cherrypy.Tool('before_handler', - Auth.check_auth) + cherrypy.config.update(config) current_dir = os.path.dirname(os.path.abspath(__file__)) fe_dir = os.path.join(current_dir, 'frontend/dist') diff --git a/src/pybind/mgr/dashboard_v2/tests/test_auth.py b/src/pybind/mgr/dashboard_v2/tests/test_auth.py index e55d234e20c..0e8846f0913 100644 --- a/src/pybind/mgr/dashboard_v2/tests/test_auth.py +++ b/src/pybind/mgr/dashboard_v2/tests/test_auth.py @@ -10,6 +10,7 @@ from mock import patch from .helper import ControllerTestCase from ..controllers.auth import Auth +from ..tools import Session class Ping(object): @@ -48,7 +49,41 @@ class AuthTest(ControllerTestCase): self._post("/api/auth", {'username': 'admin', 'password': 'admin'}) self.assertStatus('201 Created') self.assertJsonBody({"username": "admin"}) - self.assertEqual(sess_mock.get(Auth.SESSION_KEY), 'admin') + self.assertEqual(sess_mock.get(Session.USERNAME), 'admin') + + def test_login_stay_signed_in(self): + sess_mock = RamSession() + with patch('cherrypy.session', sess_mock, create=True): + self._post("/api/auth", { + 'username': 'admin', + 'password': 'admin', + 'stay_signed_in': True}) + self.assertStatus('201 Created') + self.assertEqual(sess_mock.get( + Session.EXPIRE_AT_BROWSER_CLOSE), False) + for _, content in self.cookies: + parts = map(str.strip, content.split(';')) + parts = {k: v for k, v in (part.split('=') for part in parts)} + if Session.NAME in parts: + self.assertIn('expires', parts) + self.assertIn('Max-Age', parts) + + def test_login_not_stay_signed_in(self): + sess_mock = RamSession() + with patch('cherrypy.session', sess_mock, create=True): + self._post("/api/auth", { + 'username': 'admin', + 'password': 'admin', + 'stay_signed_in': False}) + self.assertStatus('201 Created') + self.assertEqual(sess_mock.get( + Session.EXPIRE_AT_BROWSER_CLOSE), True) + for _, content in self.cookies: + parts = map(str.strip, content.split(';')) + parts = {k: v for k, v in (part.split('=') for part in parts)} + if Session.NAME in parts: + self.assertNotIn('expires', parts) + self.assertNotIn('Max-Age', parts) def test_login_invalid(self): sess_mock = RamSession() @@ -56,35 +91,35 @@ class AuthTest(ControllerTestCase): self._post("/api/auth", {'username': 'admin', 'password': 'inval'}) self.assertStatus('403 Forbidden') self.assertJsonBody({"detail": "Invalid credentials"}) - self.assertEqual(sess_mock.get(Auth.SESSION_KEY), None) + self.assertEqual(sess_mock.get(Session.USERNAME), None) def test_logout(self): sess_mock = RamSession() with patch('cherrypy.session', sess_mock, create=True): self._post("/api/auth", {'username': 'admin', 'password': 'admin'}) - self.assertEqual(sess_mock.get(Auth.SESSION_KEY), 'admin') + self.assertEqual(sess_mock.get(Session.USERNAME), 'admin') self._delete("/api/auth") self.assertStatus('204 No Content') self.assertBody('') - self.assertEqual(sess_mock.get(Auth.SESSION_KEY), None) + self.assertEqual(sess_mock.get(Session.USERNAME), None) def test_session_expire(self): sess_mock = RamSession() with patch('cherrypy.session', sess_mock, create=True): self._post("/api/auth", {'username': 'admin', 'password': 'admin'}) self.assertStatus('201 Created') - self.assertEqual(sess_mock.get(Auth.SESSION_KEY), 'admin') + self.assertEqual(sess_mock.get(Session.USERNAME), 'admin') self._post("/api/test/ping") self.assertStatus('200 OK') - self.assertEqual(sess_mock.get(Auth.SESSION_KEY), 'admin') + self.assertEqual(sess_mock.get(Session.USERNAME), 'admin') time.sleep(3) self._post("/api/test/ping") self.assertStatus('401 Unauthorized') - self.assertEqual(sess_mock.get(Auth.SESSION_KEY), None) + self.assertEqual(sess_mock.get(Session.USERNAME), None) def test_unauthorized(self): sess_mock = RamSession() with patch('cherrypy.session', sess_mock, create=True): self._post("/api/test/ping") self.assertStatus('401 Unauthorized') - self.assertEqual(sess_mock.get(Auth.SESSION_KEY), None) + self.assertEqual(sess_mock.get(Session.USERNAME), None) diff --git a/src/pybind/mgr/dashboard_v2/tools.py b/src/pybind/mgr/dashboard_v2/tools.py index ee3e276e0bb..e7631271db4 100644 --- a/src/pybind/mgr/dashboard_v2/tools.py +++ b/src/pybind/mgr/dashboard_v2/tools.py @@ -17,17 +17,19 @@ def ApiController(path): def decorate(cls): cls._cp_controller_ = True cls._cp_path_ = path + config = { + 'tools.sessions.on': True, + 'tools.sessions.name': Session.NAME, + 'tools.session_expire_at_browser_close.on': True + } if not hasattr(cls, '_cp_config'): cls._cp_config = dict(cls._cp_config_default) - cls._cp_config.update({ - 'tools.sessions.on': True, - 'tools.authenticate.on': False - }) + config['tools.authenticate.on'] = False else: cls._cp_config.update(cls._cp_config_default) - cls._cp_config['tools.sessions.on'] = True if 'tools.authenticate.on' not in cls._cp_config: - cls._cp_config['tools.authenticate.on'] = False + config['tools.authenticate.on'] = False + cls._cp_config.update(config) return cls return decorate @@ -270,3 +272,43 @@ def detail_route(methods): func.detail_route_methods = [m.upper() for m in methods] return func return decorator + + +class Session(object): + """ + This class contains all relevant settings related to cherrypy.session. + """ + NAME = 'session_id' + + # The keys used to store the information in the cherrypy.session. + USERNAME = '_username' + TS = '_ts' + EXPIRE_AT_BROWSER_CLOSE = '_expire_at_browser_close' + + # The default values. + DEFAULT_EXPIRE = 1200.0 + + +class SessionExpireAtBrowserCloseTool(cherrypy.Tool): + """ + A CherryPi Tool which takes care that the cookie does not expire + at browser close if the 'Keep me logged in' checkbox was selected + on the login page. + """ + def __init__(self): + cherrypy.Tool.__init__(self, 'before_finalize', self._callback) + + def _callback(self): + # Shall the cookie expire at browser close? + expire_at_browser_close = cherrypy.session.get( + Session.EXPIRE_AT_BROWSER_CLOSE, True) + if expire_at_browser_close: + # Get the cookie and its name. + cookie = cherrypy.response.cookie + name = cherrypy.request.config.get( + 'tools.sessions.name', Session.NAME) + # Make the cookie a session cookie by purging the + # fields 'expires' and 'max-age'. + if name in cookie: + del cookie[name]['expires'] + del cookie[name]['max-age'] -- 2.39.5