From 2f5e7c33925c51c56b074453c50c4286375333eb Mon Sep 17 00:00:00 2001 From: Ricardo Dias Date: Tue, 3 Jul 2018 11:32:54 +0100 Subject: [PATCH] mgr/dashboard: backend: JWT based authentication Signed-off-by: Ricardo Dias --- ceph.spec.in | 4 + debian/control | 2 + qa/tasks/mgr/dashboard/helper.py | 23 ++- qa/tasks/mgr/dashboard/test_auth.py | 116 +++++++----- qa/tasks/mgr/dashboard/test_role.py | 6 +- qa/tasks/mgr/dashboard/test_user.py | 14 +- .../mgr/dashboard/controllers/__init__.py | 9 +- src/pybind/mgr/dashboard/controllers/auth.py | 31 ++-- src/pybind/mgr/dashboard/controllers/docs.py | 86 ++++++--- src/pybind/mgr/dashboard/controllers/role.py | 1 + src/pybind/mgr/dashboard/controllers/user.py | 4 +- src/pybind/mgr/dashboard/module.py | 26 +-- src/pybind/mgr/dashboard/requirements.txt | 1 + .../mgr/dashboard/services/access_control.py | 36 +++- src/pybind/mgr/dashboard/services/auth.py | 168 +++++++++++++++--- src/pybind/mgr/dashboard/tests/helper.py | 2 - .../dashboard/tests/test_access_control.py | 48 +++-- src/pybind/mgr/dashboard/tools.py | 52 +----- 18 files changed, 416 insertions(+), 213 deletions(-) diff --git a/ceph.spec.in b/ceph.spec.in index 70e8612de57..a0e47189219 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -260,12 +260,14 @@ BuildRequires: python3-Cython %if 0%{with make_check} %if 0%{?fedora} || 0%{?rhel} BuildRequires: python%{_python_buildid}-cherrypy +BuildRequires: python%{_python_buildid}-jwt BuildRequires: python%{_python_buildid}-routes BuildRequires: python%{_python_buildid}-werkzeug BuildRequires: python%{_python_buildid}-bcrypt %endif %if 0%{?suse_version} BuildRequires: python%{_python_buildid}-CherryPy +BuildRequires: python%{_python_buildid}-PyJWT BuildRequires: python%{_python_buildid}-Routes BuildRequires: python%{_python_buildid}-Werkzeug BuildRequires: python%{_python_buildid}-numpy-devel @@ -420,6 +422,7 @@ Requires: python%{_python_buildid}-pecan Requires: python%{_python_buildid}-six %if 0%{?fedora} || 0%{?rhel} Requires: python%{_python_buildid}-cherrypy +Requires: python%{_python_buildid}-jwt Requires: python%{_python_buildid}-jinja2 Requires: python%{_python_buildid}-routes Requires: python%{_python_buildid}-werkzeug @@ -428,6 +431,7 @@ Requires: python%{_python_buildid}-bcrypt %endif %if 0%{?suse_version} Requires: python%{_python_buildid}-CherryPy +Requires: python%{_python_buildid}-PyJWT Requires: python%{_python_buildid}-Routes Requires: python%{_python_buildid}-Jinja2 Requires: python%{_python_buildid}-Werkzeug diff --git a/debian/control b/debian/control index e4d0d9ad11e..f79a3294d27 100644 --- a/debian/control +++ b/debian/control @@ -54,6 +54,7 @@ Build-Depends: bc, python (>= 2.7), python-all-dev, python-cherrypy3, + python-jwt, python-nose, python-pecan, python-bcrypt, @@ -179,6 +180,7 @@ Package: ceph-mgr Architecture: linux-any Depends: ceph-base (= ${binary:Version}), python-cherrypy3, + python-jwt, python-jinja2, python-openssl, python-pecan, diff --git a/qa/tasks/mgr/dashboard/helper.py b/qa/tasks/mgr/dashboard/helper.py index 610d07a7342..7fef4b903ae 100644 --- a/qa/tasks/mgr/dashboard/helper.py +++ b/qa/tasks/mgr/dashboard/helper.py @@ -26,6 +26,7 @@ class DashboardTestCase(MgrTestCase): CEPHFS = False _session = None # type: requests.sessions.Session + _token = None _resp = None # type: requests.models.Response _loggedin = False _base_uri = None @@ -71,12 +72,14 @@ class DashboardTestCase(MgrTestCase): if cls._loggedin: cls.logout() cls._post('/api/auth', {'username': username, 'password': password}) + cls._token = cls.jsonBody()['token'] cls._loggedin = True @classmethod def logout(cls): if cls._loggedin: cls._delete('/api/auth') + cls._token = None cls._loggedin = False @classmethod @@ -101,6 +104,10 @@ class DashboardTestCase(MgrTestCase): return execute return wrapper + @classmethod + def set_jwt_token(cls, token): + cls._token = token + @classmethod def setUpClass(cls): super(DashboardTestCase, cls).setUpClass() @@ -134,6 +141,7 @@ class DashboardTestCase(MgrTestCase): # wait for mds restart to complete... cls.fs.wait_for_daemons() + cls._token = None cls._session = requests.Session() cls._resp = None @@ -155,17 +163,24 @@ class DashboardTestCase(MgrTestCase): def _request(cls, url, method, data=None, params=None): url = "{}{}".format(cls._base_uri, url) log.info("request %s to %s", method, url) + headers = { + 'Content-Type': 'application/json' + } + if cls._token: + headers['Authorization'] = "Bearer {}".format(cls._token) + if method == 'GET': - cls._resp = cls._session.get(url, params=params, verify=False) + cls._resp = cls._session.get(url, params=params, verify=False, + headers=headers) elif method == 'POST': cls._resp = cls._session.post(url, json=data, params=params, - verify=False) + verify=False, headers=headers) elif method == 'DELETE': cls._resp = cls._session.delete(url, json=data, params=params, - verify=False) + verify=False, headers=headers) elif method == 'PUT': cls._resp = cls._session.put(url, json=data, params=params, - verify=False) + verify=False, headers=headers) else: assert False try: diff --git a/qa/tasks/mgr/dashboard/test_auth.py b/qa/tasks/mgr/dashboard/test_auth.py index 0921b7d9b4f..0b6ab0ab521 100644 --- a/qa/tasks/mgr/dashboard/test_auth.py +++ b/qa/tasks/mgr/dashboard/test_auth.py @@ -4,6 +4,8 @@ from __future__ import absolute_import import time +import jwt + from .helper import DashboardTestCase @@ -14,58 +16,31 @@ class AuthTest(DashboardTestCase): def setUp(self): self.reset_session() - def test_a_set_login_credentials(self): - self.create_user('admin2', 'admin2', ['administrator']) - self._post("/api/auth", {'username': 'admin2', 'password': 'admin2'}) - self.assertStatus(201) - # self.assertJsonBody({"username": "admin2"}) - data = self.jsonBody() - self.assertIn('username', data) - self.assertEqual(data['username'], "admin2") - self.assertIn('permissions', data) - for scope, perms in data['permissions'].items(): + def _validate_jwt_token(self, token, username, permissions): + payload = jwt.decode(token, verify=False) + self.assertIn('username', payload) + self.assertEqual(payload['username'], username) + + for scope, perms in permissions.items(): self.assertIsNotNone(scope) self.assertIn('read', perms) self.assertIn('update', perms) self.assertIn('create', perms) self.assertIn('delete', perms) + + def test_a_set_login_credentials(self): + self.create_user('admin2', 'admin2', ['administrator']) + self._post("/api/auth", {'username': 'admin2', 'password': 'admin2'}) + self.assertStatus(201) + data = self.jsonBody() + self._validate_jwt_token(data['token'], "admin2", data['permissions']) self.delete_user('admin2') def test_login_valid(self): self._post("/api/auth", {'username': 'admin', 'password': 'admin'}) self.assertStatus(201) data = self.jsonBody() - self.assertIn('username', data) - self.assertEqual(data['username'], "admin") - self.assertIn('permissions', data) - for scope, perms in data['permissions'].items(): - self.assertIsNotNone(scope) - self.assertIn('read', perms) - self.assertIn('update', perms) - self.assertIn('create', perms) - self.assertIn('delete', perms) - - def test_login_stay_signed_in(self): - self._post("/api/auth", { - 'username': 'admin', - 'password': 'admin', - 'stay_signed_in': True}) - self.assertStatus(201) - self.assertIn('session_id', self.cookies()) - for cookie in self.cookies(): - if cookie.name == 'session_id': - self.assertIsNotNone(cookie.expires) - - def test_login_not_stay_signed_in(self): - self._post("/api/auth", { - 'username': 'admin', - 'password': 'admin', - 'stay_signed_in': False}) - self.assertStatus(201) - self.assertIn('session_id', self.cookies()) - for cookie in self.cookies(): - if cookie.name == 'session_id': - self.assertIsNone(cookie.expires) + self._validate_jwt_token(data['token'], "admin", data['permissions']) def test_login_invalid(self): self._post("/api/auth", {'username': 'admin', 'password': 'inval'}) @@ -89,23 +64,72 @@ class AuthTest(DashboardTestCase): def test_logout(self): self._post("/api/auth", {'username': 'admin', 'password': 'admin'}) + self.assertStatus(201) + data = self.jsonBody() + self._validate_jwt_token(data['token'], "admin", data['permissions']) + self.set_jwt_token(data['token']) self._delete("/api/auth") self.assertStatus(204) - self.assertBody('') self._get("/api/host") self.assertStatus(401) + self.set_jwt_token(None) - def test_session_expire(self): - self._ceph_cmd(['dashboard', 'set-session-expire', '2']) + def test_token_ttl(self): + self._ceph_cmd(['dashboard', 'set-jwt-token-ttl', '5']) self._post("/api/auth", {'username': 'admin', 'password': 'admin'}) self.assertStatus(201) + self.set_jwt_token(self.jsonBody()['token']) self._get("/api/host") self.assertStatus(200) - time.sleep(3) + time.sleep(6) self._get("/api/host") self.assertStatus(401) - self._ceph_cmd(['dashboard', 'set-session-expire', '1200']) + self._ceph_cmd(['dashboard', 'set-jwt-token-ttl', '28800']) + self.set_jwt_token(None) + + def test_remove_from_blacklist(self): + self._ceph_cmd(['dashboard', 'set-jwt-token-ttl', '5']) + self._post("/api/auth", {'username': 'admin', 'password': 'admin'}) + self.assertStatus(201) + self.set_jwt_token(self.jsonBody()['token']) + # the following call adds the token to the blacklist + self._delete("/api/auth") + self.assertStatus(204) + self._get("/api/host") + self.assertStatus(401) + time.sleep(6) + self._ceph_cmd(['dashboard', 'set-jwt-token-ttl', '28800']) + self.set_jwt_token(None) + self._post("/api/auth", {'username': 'admin', 'password': 'admin'}) + self.assertStatus(201) + self.set_jwt_token(self.jsonBody()['token']) + # the following call removes expired tokens from the blacklist + self._delete("/api/auth") + self.assertStatus(204) def test_unauthorized(self): self._get("/api/host") self.assertStatus(401) + + def test_invalidate_token_by_admin(self): + self._get("/api/host") + self.assertStatus(401) + self.create_user('user', 'user', ['read-only']) + time.sleep(1) + self._post("/api/auth", {'username': 'user', 'password': 'user'}) + self.assertStatus(201) + self.set_jwt_token(self.jsonBody()['token']) + self._get("/api/host") + self.assertStatus(200) + time.sleep(1) + self._ceph_cmd(['dashboard', 'ac-user-set-password', 'user', 'user2']) + time.sleep(1) + self._get("/api/host") + self.assertStatus(401) + self.set_jwt_token(None) + self._post("/api/auth", {'username': 'user', 'password': 'user2'}) + self.assertStatus(201) + self.set_jwt_token(self.jsonBody()['token']) + self._get("/api/host") + self.assertStatus(200) + self.delete_user("user") diff --git a/qa/tasks/mgr/dashboard/test_role.py b/qa/tasks/mgr/dashboard/test_role.py index 120279def3a..6b0e35b2441 100644 --- a/qa/tasks/mgr/dashboard/test_role.py +++ b/qa/tasks/mgr/dashboard/test_role.py @@ -110,11 +110,12 @@ class RoleTest(DashboardTestCase): component='role') def test_delete_role_associated_with_user(self): + self.create_user("user", "user", ['read-only']) self._create_role(name='role1', description='Description 1', scopes_permissions={'user': ['create', 'read', 'update', 'delete']}) self.assertStatus(201) - self._put('/api/user/admin', {'roles': ['role1']}) + self._put('/api/user/user', {'roles': ['role1']}) self.assertStatus(200) self._delete('/api/role/role1') @@ -122,10 +123,11 @@ class RoleTest(DashboardTestCase): self.assertError(code='role_is_associated_with_user', component='role') - self._put('/api/user/admin', {'roles': ['administrator']}) + self._put('/api/user/user', {'roles': ['administrator']}) self.assertStatus(200) self._delete('/api/role/role1') self.assertStatus(204) + self.delete_user("user") def test_update_role_does_not_exist(self): self._put('/api/role/role2', {}) diff --git a/qa/tasks/mgr/dashboard/test_user.py b/qa/tasks/mgr/dashboard/test_user.py index 57521da7de4..7af3442d422 100644 --- a/qa/tasks/mgr/dashboard/test_user.py +++ b/qa/tasks/mgr/dashboard/test_user.py @@ -29,6 +29,7 @@ class UserTest(DashboardTestCase): email='my@email.com', roles=['administrator']) self.assertStatus(201) + user = self.jsonBody() self._get('/api/user/user1') self.assertStatus(200) @@ -36,7 +37,8 @@ class UserTest(DashboardTestCase): 'username': 'user1', 'name': 'My Name', 'email': 'my@email.com', - 'roles': ['administrator'] + 'roles': ['administrator'], + 'lastUpdate': user['lastUpdate'] }) self._put('/api/user/user1', { @@ -45,11 +47,13 @@ class UserTest(DashboardTestCase): 'roles': ['block-manager'], }) self.assertStatus(200) + user = self.jsonBody() self.assertJsonBody({ 'username': 'user1', 'name': 'My New Name', 'email': 'mynew@email.com', - 'roles': ['block-manager'] + 'roles': ['block-manager'], + 'lastUpdate': user['lastUpdate'] }) self._delete('/api/user/user1') @@ -58,11 +62,15 @@ class UserTest(DashboardTestCase): def test_list_users(self): self._get('/api/user') self.assertStatus(200) + user = self.jsonBody() + self.assertEqual(len(user), 1) + user = user[0] self.assertJsonBody([{ 'username': 'admin', 'name': None, 'email': None, - 'roles': ['administrator'] + 'roles': ['administrator'], + 'lastUpdate': user['lastUpdate'] }]) def test_create_user_already_exists(self): diff --git a/src/pybind/mgr/dashboard/controllers/__init__.py b/src/pybind/mgr/dashboard/controllers/__init__.py index ed9c7c032f1..5426ee47067 100644 --- a/src/pybind/mgr/dashboard/controllers/__init__.py +++ b/src/pybind/mgr/dashboard/controllers/__init__.py @@ -22,11 +22,11 @@ import cherrypy from .. import logger from ..security import Scope, Permission from ..settings import Settings -from ..tools import Session, wraps, getargspec, TaskManager +from ..tools import wraps, getargspec, TaskManager from ..exceptions import ViewCacheNoDataException, DashboardException, \ ScopeNotValid, PermissionNotValid from ..services.exception import serialize_dashboard_exception -from ..services.auth import AuthManager +from ..services.auth import AuthManager, JwtManager class Controller(object): @@ -57,9 +57,6 @@ class Controller(object): cls._security_scope = self.security_scope config = { - 'tools.sessions.on': True, - 'tools.sessions.name': Session.NAME, - 'tools.session_expire_at_browser_close.on': True, 'tools.dashboard_exception_handler.on': True, 'tools.authenticate.on': self.secure, } @@ -482,7 +479,7 @@ class BaseController(object): if scope is None: raise Exception("Cannot verify permissions without scope security" " defined") - username = cherrypy.session.get(Session.USERNAME) + username = JwtManager.LOCAL_USER.username return AuthManager.authorize(username, scope, permissions) @classmethod diff --git a/src/pybind/mgr/dashboard/controllers/auth.py b/src/pybind/mgr/dashboard/controllers/auth.py index 22a16754225..9c0effd48f7 100644 --- a/src/pybind/mgr/dashboard/controllers/auth.py +++ b/src/pybind/mgr/dashboard/controllers/auth.py @@ -1,40 +1,30 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import -import time - import cherrypy from . import ApiController, RESTController from .. import logger from ..exceptions import DashboardException -from ..services.auth import AuthManager -from ..tools import Session +from ..services.auth import AuthManager, JwtManager @ApiController('/auth', secure=False) class Auth(RESTController): """ - Provide login and logout actions. - - Supported config-keys: - - | KEY | DEFAULT | DESCR | - ------------------------------------------------------------------------| - | session-expire | 1200 | Session will expire after | - | | seconds without activity | + Provide authenticates and returns JWT token. """ - def create(self, username, password, stay_signed_in=False): - now = time.time() + def create(self, username, password): user_perms = AuthManager.authenticate(username, password) if user_perms is not None: - cherrypy.session.regenerate() - cherrypy.session[Session.USERNAME] = username - cherrypy.session[Session.TS] = now - cherrypy.session[Session.EXPIRE_AT_BROWSER_CLOSE] = not stay_signed_in logger.debug('Login successful') + token = JwtManager.gen_token(username) + token = token.decode('utf-8') + logger.debug("JWT Token: %s", token) + cherrypy.response.headers['Authorization'] = "Bearer: {}".format(token) return { + 'token': token, 'username': username, 'permissions': user_perms } @@ -45,6 +35,5 @@ class Auth(RESTController): component='auth') def bulk_delete(self): - logger.debug('Logout successful') - cherrypy.session[Session.USERNAME] = None - cherrypy.session[Session.TS] = None + token = JwtManager.get_token_from_header() + JwtManager.blacklist_token(token) diff --git a/src/pybind/mgr/dashboard/controllers/docs.py b/src/pybind/mgr/dashboard/controllers/docs.py index 2998c0cbc63..782a14f9abd 100644 --- a/src/pybind/mgr/dashboard/controllers/docs.py +++ b/src/pybind/mgr/dashboard/controllers/docs.py @@ -9,7 +9,7 @@ from . import Controller, BaseController, Endpoint, ENDPOINT_MAP from .. import logger, mgr -@Controller('/docs') +@Controller('/docs', secure=False) class Docs(BaseController): @classmethod @@ -62,15 +62,10 @@ class Docs(BaseController): return None return { - 'in': "body", - 'name': "body", - 'description': "", - 'required': True, - 'schema': { - 'type': "object", - 'required': required, - 'properties': props - } + 'title': '', + 'type': "object", + 'required': required, + 'properties': props } @classmethod @@ -111,7 +106,9 @@ class Docs(BaseController): res = { 'name': param['name'], 'in': ptype, - 'type': cls._gen_type(param) + 'schema': { + 'type': cls._gen_type(param) + } } if param['required']: res['required'] = True @@ -149,11 +146,6 @@ class Docs(BaseController): params.extend([self._gen_param(p, 'query') for p in endpoint.query_params]) - if method.lower() in ['post', 'put']: - body_params = self._gen_body_param(endpoint.body_params) - if body_params: - params.append(body_params) - methods[method.lower()] = { 'tags': [endpoint.group], 'summary': "", @@ -164,10 +156,23 @@ class Docs(BaseController): "application/json" ], 'parameters': params, - 'responses': self._gen_responses_descriptions(method), - "security": [""] + 'responses': self._gen_responses_descriptions(method) } + if method.lower() in ['post', 'put']: + body_params = self._gen_body_param(endpoint.body_params) + if body_params: + methods[method.lower()]['requestBody'] = { + 'content': { + 'application/json': { + 'schema': body_params + } + } + } + + if endpoint.is_secure: + methods[method.lower()]['security'] = [{'jwt': []}] + if not skip: paths[path[len(baseUrl):]] = methods @@ -180,7 +185,7 @@ class Docs(BaseController): scheme = 'http' spec = { - 'swagger': "2.0", + 'openapi': "3.0.0", 'info': { 'description': "Please note that this API is not an official " "Ceph REST API to be used by third-party " @@ -193,9 +198,19 @@ class Docs(BaseController): }, 'host': host, 'basePath': baseUrl, + 'servers': [{'url': "{}{}".format(cherrypy.request.base, baseUrl)}], 'tags': self._gen_tags(all_endpoints), 'schemes': [scheme], - 'paths': paths + 'paths': paths, + 'components': { + 'securitySchemes': { + 'jwt': { + 'type': 'http', + 'scheme': 'bearer', + 'bearerFormat': 'JWT' + } + } + } } return spec @@ -208,13 +223,28 @@ class Docs(BaseController): def api_all_json(self): return self._gen_spec(True, "/api") - @Endpoint(json_response=False) - def __call__(self, all_endpoints=False): + def _swagger_ui_page(self, all_endpoints=False, token=None): base = cherrypy.request.base if all_endpoints: spec_url = "{}/docs/api-all.json".format(base) else: spec_url = "{}/docs/api.json".format(base) + + auth_header = cherrypy.request.headers.get('authorization') + jwt_token = "" + if auth_header is not None: + scheme, params = auth_header.split(' ', 1) + if scheme.lower() == 'bearer': + jwt_token = params + else: + if token is not None: + jwt_token = token + + apiKeyCallback = """, onComplete: () => {{ + ui.preauthorizeApiKey('jwt', '{}'); + }} + """.format(jwt_token) + page = """ @@ -261,12 +291,22 @@ class Docs(BaseController): SwaggerUIBundle.presets.apis ], layout: "BaseLayout" + {} }}) window.ui = ui }} - """.format(spec_url) + """.format(spec_url, apiKeyCallback) return page + + @Endpoint(json_response=False) + def __call__(self, all_endpoints=False): + return self._swagger_ui_page(all_endpoints) + + @Endpoint('POST', path="/", json_response=False, + query_params="{all_endpoints}") + def _with_token(self, token, all_endpoints=False): + return self._swagger_ui_page(all_endpoints, token) diff --git a/src/pybind/mgr/dashboard/controllers/role.py b/src/pybind/mgr/dashboard/controllers/role.py index 32714423051..4e5bc596705 100644 --- a/src/pybind/mgr/dashboard/controllers/role.py +++ b/src/pybind/mgr/dashboard/controllers/role.py @@ -83,6 +83,7 @@ class Role(RESTController): Role._validate_permissions(scopes_permissions) Role._set_permissions(role, scopes_permissions) role.description = description + ACCESS_CTRL_DB.update_users_with_roles(role) ACCESS_CTRL_DB.save() return Role._role_to_dict(role) diff --git a/src/pybind/mgr/dashboard/controllers/user.py b/src/pybind/mgr/dashboard/controllers/user.py index a0671c5fe1c..d3cff95543a 100644 --- a/src/pybind/mgr/dashboard/controllers/user.py +++ b/src/pybind/mgr/dashboard/controllers/user.py @@ -8,7 +8,7 @@ from ..exceptions import DashboardException, UserAlreadyExists, \ UserDoesNotExist from ..security import Scope from ..services.access_control import ACCESS_CTRL_DB, SYSTEM_ROLES -from ..tools import Session +from ..services.auth import JwtManager @ApiController('/user', Scope.USER) @@ -62,7 +62,7 @@ class User(RESTController): return User._user_to_dict(user) def delete(self, username): - session_username = cherrypy.session.get(Session.USERNAME) + session_username = JwtManager.get_username() if session_username == username: raise DashboardException(msg='Cannot delete current user', code='cannot_delete_current_user', diff --git a/src/pybind/mgr/dashboard/module.py b/src/pybind/mgr/dashboard/module.py index 2140294e5a0..9ba0a545dbe 100644 --- a/src/pybind/mgr/dashboard/module.py +++ b/src/pybind/mgr/dashboard/module.py @@ -59,9 +59,8 @@ if 'COVERAGE_ENABLED' in os.environ: # pylint: disable=wrong-import-position from . import logger, mgr from .controllers import generate_routes, json_error_page -from .tools import SessionExpireAtBrowserCloseTool, NotificationQueue, \ - RequestLoggingTool, TaskManager -from .services.auth import AuthManager, AuthManagerTool +from .tools import NotificationQueue, RequestLoggingTool, TaskManager +from .services.auth import AuthManager, AuthManagerTool, JwtManager from .services.access_control import ACCESS_CONTROL_COMMANDS, \ handle_access_control_command from .services.exception import dashboard_exception_handler @@ -133,7 +132,6 @@ class CherryPyConfig(object): # Initialize custom handlers. cherrypy.tools.authenticate = AuthManagerTool() - cherrypy.tools.session_expire_at_browser_close = SessionExpireAtBrowserCloseTool() cherrypy.tools.request_logging = RequestLoggingTool() cherrypy.tools.dashboard_exception_handler = HandlerWrapperTool(dashboard_exception_handler, priority=31) @@ -220,11 +218,16 @@ class Module(MgrModule, CherryPyConfig): COMMANDS = [ { - 'cmd': 'dashboard set-session-expire ' + 'cmd': 'dashboard set-jwt-token-ttl ' 'name=seconds,type=CephInt', - 'desc': 'Set the session expire timeout', + 'desc': 'Set the JWT token TTL in seconds', 'perm': 'w' }, + { + 'cmd': 'dashboard get-jwt-token-ttl', + 'desc': 'Get the JWT token TTL in seconds', + 'perm': 'r' + }, { "cmd": "dashboard create-self-signed-cert", "desc": "Create self signed certificate", @@ -237,7 +240,7 @@ class Module(MgrModule, CherryPyConfig): OPTIONS = [ {'name': 'server_addr'}, {'name': 'server_port'}, - {'name': 'session-expire'}, + {'name': 'jwt_token_ttl'}, {'name': 'password'}, {'name': 'url_prefix'}, {'name': 'username'}, @@ -328,9 +331,12 @@ class Module(MgrModule, CherryPyConfig): res = handle_access_control_command(cmd) if res[0] != -errno.ENOSYS: return res - elif cmd['prefix'] == 'dashboard set-session-expire': - self.set_config('session-expire', str(cmd['seconds'])) - return 0, 'Session expiration timeout updated', '' + elif cmd['prefix'] == 'dashboard set-jwt-token-ttl': + self.set_config('jwt_token_ttl', str(cmd['seconds'])) + return 0, 'JWT token TTL updated', '' + elif cmd['prefix'] == 'dashboard get-jwt-token-ttl': + ttl = self.get_config('jwt_token_ttl', JwtManager.JWT_TOKEN_TTL) + return 0, str(ttl), '' elif cmd['prefix'] == 'dashboard create-self-signed-cert': self.create_self_signed_cert() return 0, 'Self-signed certificate created', '' diff --git a/src/pybind/mgr/dashboard/requirements.txt b/src/pybind/mgr/dashboard/requirements.txt index 73428a9d443..a256a3a0483 100644 --- a/src/pybind/mgr/dashboard/requirements.txt +++ b/src/pybind/mgr/dashboard/requirements.txt @@ -19,6 +19,7 @@ portend==2.2 py==1.5.2 pycodestyle==2.3.1 pycparser==2.18 +PyJWT==1.6.4 pylint==1.8.2 pyopenssl==17.5.0 pytest==3.3.2 diff --git a/src/pybind/mgr/dashboard/services/access_control.py b/src/pybind/mgr/dashboard/services/access_control.py index 423df81c7a8..43babfb3999 100644 --- a/src/pybind/mgr/dashboard/services/access_control.py +++ b/src/pybind/mgr/dashboard/services/access_control.py @@ -6,6 +6,7 @@ from __future__ import absolute_import import errno import json import threading +import time import bcrypt @@ -152,7 +153,8 @@ SYSTEM_ROLES = { class User(object): - def __init__(self, username, password, name=None, email=None, roles=None): + def __init__(self, username, password, name=None, email=None, roles=None, + lastUpdate=None): self.username = username self.password = password self.name = name @@ -161,21 +163,32 @@ class User(object): self.roles = set() else: self.roles = roles + if lastUpdate is None: + self.refreshLastUpdate() + else: + self.lastUpdate = lastUpdate + + def refreshLastUpdate(self): + self.lastUpdate = int(time.mktime(time.gmtime())) def set_password(self, password): self.password = password_hash(password) + self.refreshLastUpdate() def set_roles(self, roles): self.roles = set(roles) + self.refreshLastUpdate() def add_roles(self, roles): self.roles = self.roles.union(set(roles)) + self.refreshLastUpdate() def del_roles(self, roles): for role in roles: if role not in self.roles: raise RoleNotInUser(role.name, self.username) self.roles.difference_update(set(roles)) + self.refreshLastUpdate() def authorize(self, scope, permissions): for role in self.roles: @@ -201,13 +214,15 @@ class User(object): 'password': self.password, 'roles': sorted([r.name for r in self.roles]), 'name': self.name, - 'email': self.email + 'email': self.email, + 'lastUpdate': self.lastUpdate } @classmethod def from_dict(cls, u_dict, roles): return User(u_dict['username'], u_dict['password'], u_dict['name'], - u_dict['email'], set([roles[r] for r in u_dict['roles']])) + u_dict['email'], set([roles[r] for r in u_dict['roles']]), + u_dict['lastUpdate']) class AccessControlDB(object): @@ -268,6 +283,14 @@ class AccessControlDB(object): raise UserDoesNotExist(username) del self.users[username] + def update_users_with_roles(self, role): + with self.lock: + if not role: + return + for _, user in self.users.items(): + if role in user.roles: + user.refreshLastUpdate() + def save(self): with self.lock: db = { @@ -305,7 +328,7 @@ class AccessControlDB(object): def load(cls): logger.info("AC: Loading user roles DB version=%s", cls.VERSION) - json_db = mgr.get_store(cls.accessdb_config_key(), None) + json_db = mgr.get_store(cls.accessdb_config_key()) if json_db is None: logger.debug("AC: No DB v%s found, creating new...", cls.VERSION) db = cls(cls.VERSION, {}, {}) @@ -502,6 +525,7 @@ Username and password updated''', '' role = ACCESS_CTRL_DB.get_role(rolename) perms_array = [perm.strip() for perm in permissions] role.set_scope_permissions(scopename, perms_array) + ACCESS_CTRL_DB.update_users_with_roles(role) ACCESS_CTRL_DB.save() return 0, json.dumps(role.to_dict()), '' except RoleDoesNotExist as ex: @@ -523,6 +547,7 @@ Username and password updated''', '' try: role = ACCESS_CTRL_DB.get_role(rolename) role.del_scope_permissions(scopename) + ACCESS_CTRL_DB.update_users_with_roles(role) ACCESS_CTRL_DB.save() return 0, json.dumps(role.to_dict()), '' except RoleDoesNotExist as ex: @@ -670,6 +695,9 @@ class LocalAuthenticator(object): def __init__(self): load_access_control_db() + def get_user(self, username): + return ACCESS_CTRL_DB.get_user(username) + def authenticate(self, username, password): try: user = ACCESS_CTRL_DB.get_user(username) diff --git a/src/pybind/mgr/dashboard/services/auth.py b/src/pybind/mgr/dashboard/services/auth.py index d07a6c31efc..61205a0eca9 100644 --- a/src/pybind/mgr/dashboard/services/auth.py +++ b/src/pybind/mgr/dashboard/services/auth.py @@ -1,13 +1,112 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import +from base64 import b64encode +import json +import os +import threading import time +import uuid import cherrypy +import jwt -from .access_control import LocalAuthenticator +from .access_control import LocalAuthenticator, UserDoesNotExist from .. import mgr, logger -from ..tools import Session + + +class JwtManager(object): + JWT_TOKEN_BLACKLIST_KEY = "jwt_token_black_list" + JWT_TOKEN_TTL = 28800 # default 8 hours + JWT_ALGORITHM = 'HS256' + _secret = None + + LOCAL_USER = threading.local() + + @staticmethod + def _gen_secret(): + secret = os.urandom(16) + return b64encode(secret).decode('utf-8') + + @classmethod + def init(cls): + # generate a new secret if it does not exist + secret = mgr.get_store('jwt_secret') + if secret is None: + secret = cls._gen_secret() + mgr.set_store('jwt_secret', secret) + cls._secret = secret + + @classmethod + def gen_token(cls, username): + if not cls._secret: + cls.init() + ttl = mgr.get_config('jwt_token_ttl', cls.JWT_TOKEN_TTL) + ttl = int(ttl) + now = int(time.mktime(time.gmtime())) + payload = { + 'iss': 'ceph-dashboard', + 'jti': str(uuid.uuid4()), + 'exp': now + ttl, + 'iat': now, + 'username': username + } + return jwt.encode(payload, cls._secret, algorithm=cls.JWT_ALGORITHM) + + @classmethod + def decode_token(cls, token): + if not cls._secret: + cls.init() + return jwt.decode(token, cls._secret, algorithms=cls.JWT_ALGORITHM) + + @classmethod + def get_token_from_header(cls): + auth_header = cherrypy.request.headers.get('authorization') + if auth_header is not None: + scheme, params = auth_header.split(' ', 1) + if scheme.lower() == 'bearer': + return params + return None + + @classmethod + def set_user(cls, token): + cls.LOCAL_USER.username = token['username'] + + @classmethod + def reset_user(cls): + cls.set_user({'username': None, 'permissions': None}) + + @classmethod + def get_username(cls): + return getattr(cls.LOCAL_USER, 'username', None) + + @classmethod + def blacklist_token(cls, token): + token = jwt.decode(token, verify=False) + blacklist_json = mgr.get_store(cls.JWT_TOKEN_BLACKLIST_KEY) + if not blacklist_json: + blacklist_json = "{}" + bl_dict = json.loads(blacklist_json) + now = time.time() + + # remove expired tokens + to_delete = [] + for jti, exp in bl_dict.items(): + if exp < now: + to_delete.append(jti) + for jti in to_delete: + del bl_dict[jti] + + bl_dict[token['jti']] = token['exp'] + mgr.set_store(cls.JWT_TOKEN_BLACKLIST_KEY, json.dumps(bl_dict)) + + @classmethod + def is_blacklisted(cls, jti): + blacklist_json = mgr.get_store(cls.JWT_TOKEN_BLACKLIST_KEY) + if not blacklist_json: + blacklist_json = "{}" + bl_dict = json.loads(blacklist_json) + return jti in bl_dict class AuthManager(object): @@ -17,6 +116,10 @@ class AuthManager(object): def initialize(cls): cls.AUTH_PROVIDER = LocalAuthenticator() + @classmethod + def get_user(cls, username): + return cls.AUTH_PROVIDER.get_user(username) + @classmethod def authenticate(cls, username, password): return cls.AUTH_PROVIDER.authenticate(username, password) @@ -32,41 +135,52 @@ class AuthManagerTool(cherrypy.Tool): 'before_handler', self._check_authentication, priority=20) def _check_authentication(self): - username = cherrypy.session.get(Session.USERNAME) - if not username: - logger.debug('Unauthorized access to %s', - cherrypy.url(relative='server')) - raise cherrypy.HTTPError(401, 'You are not authorized to access ' - 'that resource') - now = time.time() - expires = float(mgr.get_config( - 'session-expire', Session.DEFAULT_EXPIRE)) - if expires > 0: - username_ts = cherrypy.session.get(Session.TS, None) - if username_ts and float(username_ts) < (now - expires): - cherrypy.session[Session.USERNAME] = None - cherrypy.session[Session.TS] = None - logger.debug('Session expired') - raise cherrypy.HTTPError(401, - 'Session expired. You are not ' - 'authorized to access that resource') - cherrypy.session[Session.TS] = now - - self._check_authorization(username) - - def _check_authorization(self, username): + JwtManager.reset_user() + token = JwtManager.get_token_from_header() + logger.debug("AMT: token: %s", token) + if token: + try: + token = JwtManager.decode_token(token) + if not JwtManager.is_blacklisted(token['jti']): + user = AuthManager.get_user(token['username']) + if user.lastUpdate <= token['iat']: + self._check_authorization(token) + return + + logger.debug("AMT: user info changed after token was" + " issued, iat=%s lastUpdate=%s", + token['iat'], user.lastUpdate) + else: + logger.debug('AMT: Token is black-listed') + except jwt.exceptions.ExpiredSignatureError: + logger.debug("AMT: Token has expired") + except jwt.exceptions.InvalidTokenError: + logger.debug("AMT: Failed to decode token") + except UserDoesNotExist: + logger.debug("AMT: Invalid token: user %s does not exist", + token['username']) + + logger.debug('AMT: Unauthorized access to %s', + cherrypy.url(relative='server')) + raise cherrypy.HTTPError(401, 'You are not authorized to access ' + 'that resource') + + def _check_authorization(self, token): logger.debug("AMT: checking authorization...") + username = token['username'] handler = cherrypy.request.handler.callable controller = handler.__self__ sec_scope = getattr(controller, '_security_scope', None) sec_perms = getattr(handler, '_security_permissions', None) - logger.debug("AMT: checking %s access to '%s' scope", sec_perms, - sec_scope) + JwtManager.set_user(token) if not sec_scope: # controller does not define any authorization restrictions return + logger.debug("AMT: checking '%s' access to '%s' scope", sec_perms, + sec_scope) + if not sec_perms: logger.debug("Fail to check permission on: %s:%s", controller, handler) diff --git a/src/pybind/mgr/dashboard/tests/helper.py b/src/pybind/mgr/dashboard/tests/helper.py index 23c0def78f1..1a8ea7a381b 100644 --- a/src/pybind/mgr/dashboard/tests/helper.py +++ b/src/pybind/mgr/dashboard/tests/helper.py @@ -14,7 +14,6 @@ from .. import logger from ..controllers import json_error_page, generate_controller_routes from ..services.auth import AuthManagerTool from ..services.exception import dashboard_exception_handler -from ..tools import SessionExpireAtBrowserCloseTool class ControllerTestCase(helper.CPWebCase): @@ -39,7 +38,6 @@ class ControllerTestCase(helper.CPWebCase): def __init__(self, *args, **kwargs): cherrypy.tools.authenticate = AuthManagerTool() - cherrypy.tools.session_expire_at_browser_close = SessionExpireAtBrowserCloseTool() cherrypy.tools.dashboard_exception_handler = HandlerWrapperTool(dashboard_exception_handler, priority=31) cherrypy.config.update({'error_page.default': json_error_page}) diff --git a/src/pybind/mgr/dashboard/tests/test_access_control.py b/src/pybind/mgr/dashboard/tests/test_access_control.py index ed9abc225b0..3592c741f0d 100644 --- a/src/pybind/mgr/dashboard/tests/test_access_control.py +++ b/src/pybind/mgr/dashboard/tests/test_access_control.py @@ -4,6 +4,7 @@ from __future__ import absolute_import import errno import json +import time import unittest from .. import mgr @@ -28,7 +29,7 @@ class AccessControlTest(unittest.TestCase): cls.CONFIG_KEY_DICT[attr] = val @classmethod - def mock_get_config(cls, attr, default): + def mock_get_config(cls, attr, default=None): return cls.CONFIG_KEY_DICT.get(attr, default) @classmethod @@ -98,7 +99,7 @@ class AccessControlTest(unittest.TestCase): self.assertNotIn(rolename, db['roles']) def validate_persistent_user(self, username, roles, password=None, - name=None, email=None): + name=None, email=None, lastUpdate=None): db = self.load_persistent_db() self.assertIn('users', db) self.assertIn(username, db['users']) @@ -110,6 +111,8 @@ class AccessControlTest(unittest.TestCase): self.assertEqual(db['users'][username]['name'], name) if email: self.assertEqual(db['users'][username]['email'], email) + if lastUpdate: + self.assertEqual(db['users'][username]['lastUpdate'], lastUpdate) def validate_persistent_no_user(self, username): db = self.load_persistent_db() @@ -306,13 +309,16 @@ class AccessControlTest(unittest.TestCase): self.assertDictEqual(user, { 'username': username, 'password': pass_hash, + 'lastUpdate': user['lastUpdate'], 'name': '{} User'.format(username), 'email': '{}@user.com'.format(username), 'roles': [rolename] if rolename else [] }) self.validate_persistent_user(username, [rolename] if rolename else [], pass_hash, '{} User'.format(username), - '{}@user.com'.format(username)) + '{}@user.com'.format(username), + user['lastUpdate']) + return user def test_create_user_with_role(self): self.test_add_role_scope_perms() @@ -386,7 +392,7 @@ class AccessControlTest(unittest.TestCase): def test_add_user_roles(self, username='admin', roles=['pool-manager', 'block-manager']): - self.test_create_user(username) + user_orig = self.test_create_user(username) uroles = [] for role in roles: uroles.append(role) @@ -395,15 +401,17 @@ class AccessControlTest(unittest.TestCase): roles=[role]) self.assertDictContainsSubset({'roles': uroles}, user) self.validate_persistent_user(username, uroles) + self.assertGreaterEqual(user['lastUpdate'], user_orig['lastUpdate']) def test_add_user_roles2(self): - self.test_create_user() + user_orig = self.test_create_user() user = self.exec_cmd('ac-user-add-roles', username="admin", roles=['pool-manager', 'block-manager']) self.assertDictContainsSubset( {'roles': ['block-manager', 'pool-manager']}, user) self.validate_persistent_user('admin', ['block-manager', 'pool-manager']) + self.assertGreaterEqual(user['lastUpdate'], user_orig['lastUpdate']) def test_add_user_roles_not_existent_user(self): with self.assertRaises(CmdException) as ctx: @@ -424,18 +432,20 @@ class AccessControlTest(unittest.TestCase): "Role 'Invalid Role' does not exist") def test_set_user_roles(self): - self.test_create_user() + user_orig = self.test_create_user() user = self.exec_cmd('ac-user-add-roles', username="admin", roles=['pool-manager']) self.assertDictContainsSubset( {'roles': ['pool-manager']}, user) self.validate_persistent_user('admin', ['pool-manager']) - user = self.exec_cmd('ac-user-set-roles', username="admin", - roles=['rgw-manager', 'block-manager']) + self.assertGreaterEqual(user['lastUpdate'], user_orig['lastUpdate']) + user2 = self.exec_cmd('ac-user-set-roles', username="admin", + roles=['rgw-manager', 'block-manager']) self.assertDictContainsSubset( - {'roles': ['block-manager', 'rgw-manager']}, user) + {'roles': ['block-manager', 'rgw-manager']}, user2) self.validate_persistent_user('admin', ['block-manager', 'rgw-manager']) + self.assertGreaterEqual(user2['lastUpdate'], user['lastUpdate']) def test_set_user_roles_not_existent_user(self): with self.assertRaises(CmdException) as ctx: @@ -498,6 +508,7 @@ class AccessControlTest(unittest.TestCase): pass_hash = password_hash('admin', user['password']) self.assertDictEqual(user, { 'username': 'admin', + 'lastUpdate': user['lastUpdate'], 'password': pass_hash, 'name': 'admin User', 'email': 'admin@user.com', @@ -532,7 +543,7 @@ class AccessControlTest(unittest.TestCase): "'guest'") def test_set_user_info(self): - self.test_create_user() + user_orig = self.test_create_user() user = self.exec_cmd('ac-user-set-info', username='admin', name='Admin Name', email='admin@admin.com') pass_hash = password_hash('admin', user['password']) @@ -541,10 +552,12 @@ class AccessControlTest(unittest.TestCase): 'password': pass_hash, 'name': 'Admin Name', 'email': 'admin@admin.com', + 'lastUpdate': user['lastUpdate'], 'roles': [] }) self.validate_persistent_user('admin', [], pass_hash, 'Admin Name', 'admin@admin.com') + self.assertEqual(user['lastUpdate'], user_orig['lastUpdate']) def test_set_user_info_nonexistent_user(self): with self.assertRaises(CmdException) as ctx: @@ -555,7 +568,7 @@ class AccessControlTest(unittest.TestCase): self.assertEqual(str(ctx.exception), "User 'admin' does not exist") def test_set_user_password(self): - self.test_create_user() + user_orig = self.test_create_user() user = self.exec_cmd('ac-user-set-password', username='admin', password='newpass') pass_hash = password_hash('newpass', user['password']) @@ -564,10 +577,12 @@ class AccessControlTest(unittest.TestCase): 'password': pass_hash, 'name': 'admin User', 'email': 'admin@user.com', + 'lastUpdate': user['lastUpdate'], 'roles': [] }) self.validate_persistent_user('admin', [], pass_hash, 'admin User', 'admin@user.com') + self.assertGreaterEqual(user['lastUpdate'], user_orig['lastUpdate']) def test_set_user_password_nonexistent_user(self): with self.assertRaises(CmdException) as ctx: @@ -587,6 +602,7 @@ class AccessControlTest(unittest.TestCase): 'password': pass_hash, 'name': None, 'email': None, + 'lastUpdate': user['lastUpdate'], 'roles': ['administrator'] }) self.validate_persistent_user('admin', ['administrator'], pass_hash, @@ -603,6 +619,7 @@ class AccessControlTest(unittest.TestCase): 'password': pass_hash, 'name': 'admin User', 'email': 'admin@user.com', + 'lastUpdate': user['lastUpdate'], 'roles': ['read-only'] }) self.validate_persistent_user('admin', ['read-only'], pass_hash, @@ -618,7 +635,8 @@ class AccessControlTest(unittest.TestCase): "$2b$12$sd0Az7mm3FaJl8kN3b/xwOuztaN0sWUwC1SJqjM4wcDw/s5cmGbLK", "roles": ["block-manager", "test_role"], "name": "admin User", - "email": "admin@user.com" + "email": "admin@user.com", + "lastUpdate": {} }} }}, "roles": {{ @@ -633,8 +651,8 @@ class AccessControlTest(unittest.TestCase): }}, "version": 1 }} - '''.format(Scope.ISCSI, Permission.READ, Permission.UPDATE, - Scope.POOL, Permission.CREATE) + '''.format(int(round(time.time())), Scope.ISCSI, Permission.READ, + Permission.UPDATE, Scope.POOL, Permission.CREATE) load_access_control_db() role = self.exec_cmd('ac-role-show', rolename="test_role") @@ -649,6 +667,7 @@ class AccessControlTest(unittest.TestCase): user = self.exec_cmd('ac-user-show', username="admin") self.assertDictEqual(user, { 'username': 'admin', + 'lastUpdate': user['lastUpdate'], 'password': "$2b$12$sd0Az7mm3FaJl8kN3b/xwOuztaN0sWUwC1SJqjM4wcDw/s5cmGbLK", 'name': 'admin User', @@ -664,6 +683,7 @@ class AccessControlTest(unittest.TestCase): user = self.exec_cmd('ac-user-show', username="admin") self.assertDictEqual(user, { 'username': 'admin', + 'lastUpdate': user['lastUpdate'], 'password': "$2b$12$sd0Az7mm3FaJl8kN3b/xwOuztaN0sWUwC1SJqjM4wcDw/s5cmGbLK", 'name': None, diff --git a/src/pybind/mgr/dashboard/tools.py b/src/pybind/mgr/dashboard/tools.py index c65b654b5a1..662fc5cd955 100644 --- a/src/pybind/mgr/dashboard/tools.py +++ b/src/pybind/mgr/dashboard/tools.py @@ -17,6 +17,7 @@ import cherrypy from . import logger from .exceptions import ViewCacheNoDataException +from .services.auth import JwtManager class RequestLoggingTool(cherrypy.Tool): @@ -31,14 +32,9 @@ class RequestLoggingTool(cherrypy.Tool): cherrypy.request.hooks.attach('after_error_response', self.request_error, priority=5) - def _get_user(self): - if hasattr(cherrypy.serving, 'session'): - return cherrypy.session.get(Session.USERNAME) - return None - def request_begin(self): req = cherrypy.request - user = self._get_user() + user = JwtManager.get_username() if user: logger.debug("[%s:%s] [%s] [%s] %s", req.remote.ip, req.remote.port, req.method, user, req.path_info) @@ -85,7 +81,7 @@ class RequestLoggingTool(cherrypy.Tool): req = cherrypy.request res = cherrypy.response lat = time.time() - res.time - user = self._get_user() + user = JwtManager.get_username() status = res.status[:3] if isinstance(res.status, str) else res.status if 'Content-Length' in res.headers: length = self._format_bytes(res.headers['Content-Length']) @@ -219,48 +215,6 @@ class ViewCache(object): return wrapper -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) - logger.debug("expire at browser close: %s", expire_at_browser_close) - 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'. - logger.debug("expire at browser close: removing 'expires' and 'max-age'") - if name in cookie: - del cookie[name]['expires'] - del cookie[name]['max-age'] - - class NotificationQueue(threading.Thread): _ALL_TYPES_ = '__ALL__' _listeners = collections.defaultdict(set) -- 2.39.5