]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard_v2: Session expire at browser close
authorVolker Theile <vtheile@suse.com>
Sat, 27 Jan 2018 10:55:29 +0000 (11:55 +0100)
committerRicardo Dias <rdias@suse.com>
Mon, 5 Mar 2018 13:07:04 +0000 (13:07 +0000)
Add support for session expire at browser close.

Signed-off-by: Volker Theile <vtheile@suse.com>
src/pybind/mgr/dashboard_v2/controllers/auth.py
src/pybind/mgr/dashboard_v2/frontend/src/app/core/auth/login/login.component.html
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/models/credentials.model.ts
src/pybind/mgr/dashboard_v2/frontend/src/openattic-theme.scss
src/pybind/mgr/dashboard_v2/frontend/tslint.json
src/pybind/mgr/dashboard_v2/module.py
src/pybind/mgr/dashboard_v2/tests/test_auth.py
src/pybind/mgr/dashboard_v2/tools.py

index 246314f6d3d77cb24f85463575116bcedbb19128..5d28592a40cea268e2d53a35c17569c65cb0d57c 100644 (file)
@@ -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):
index 09241b8b26fef1bb3a8ced5b8c8f954d233f6664..afbabe1b130a37be50f2d4b584098361c09a6363 100644 (file)
                *ngIf="(loginForm.submitted || password.dirty) && password.invalid">Password is required</div>
         </div>
 
+        <!-- Stay signed in -->
+        <div class="checkbox">
+          <label translate>
+            <input id="stay_signed_in"
+                   name="stay_signed_in"
+                   type="checkbox"
+                   [(ngModel)]="model.stay_signed_in">
+            Keep me logged in
+          </label>
+        </div>
+
         <input type="submit"
                class="btn btn-openattic btn-block"
                [disabled]="loginForm.invalid"
index 2c2b7d76e3991345424842c10e12554b7fb28258..b33c366c0376b284ab2571c0cd4e0aa585b61785 100644 (file)
@@ -1,4 +1,5 @@
 export class Credentials {
   username: string;
   password: string;
+  stay_signed_in = false;
 }
index d34ba15a3816366b1fc3713ad7602402fdc11809..2f915fa290105679685d27a839005cfbac99ed30 100755 (executable)
@@ -793,53 +793,6 @@ h6{
 .login {
   height: 100%;
 }
-.login .utility-bar {
-  position: absolute;
-  margin: 0;
-  padding: 0;
-  top: 10px;
-  right: 10px;
-}
-.login .utility-bar>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 */
index e90833d81e506e73354d368465a0a5a03d351ae6..341e8f9cd5e68057565ab1a322901e03b2e0a69a 100644 (file)
     ],
     "typeof-compare": true,
     "unified-signatures": true,
-    "variable-name": true,
+    "variable-name": [
+      true,
+      "check-format",
+      "allow-snake-case"
+    ],
     "whitespace": [
       true,
       "check-branch",
index acb5d0c282d20684ec48b181fb6ca5a4daf37701..0c574578d392f70016add47fb1425581f158247b 100644 (file)
@@ -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')
index e55d234e20c6ded303a8eb844722b798ec38bec4..0e8846f09131a7763478aa7c9d0dd7e4ca3ef608 100644 (file)
@@ -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)
index ee3e276e0bb9ce47f6dd2d8c060dc44a6c93dd81..e7631271db43196172d9522590656d1af563f8f5 100644 (file)
@@ -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']