mgr/dashboard: backend: JWT based authentication

Signed-off-by: Ricardo Dias <rdias@suse.com>
This commit is contained in:
Ricardo Dias 2018-07-03 11:32:54 +01:00
parent 5727f9b455
commit 2f5e7c3392
No known key found for this signature in database
GPG Key ID: 74390C579BD37B68
18 changed files with 414 additions and 211 deletions

View File

@ -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

2
debian/control vendored
View File

@ -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,

View File

@ -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:

View File

@ -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")

View File

@ -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', {})

View File

@ -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):

View File

@ -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

View File

@ -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 <expires> |
| | 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)

View File

@ -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 = """
<!DOCTYPE html>
<html>
@ -261,12 +291,22 @@ class Docs(BaseController):
SwaggerUIBundle.presets.apis
],
layout: "BaseLayout"
{}
}})
window.ui = ui
}}
</script>
</body>
</html>
""".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)

View File

@ -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)

View File

@ -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',

View File

@ -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', ''

View File

@ -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

View File

@ -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)

View File

@ -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
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
self._check_authorization(username)
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'])
def _check_authorization(self, 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)

View File

@ -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})

View File

@ -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,

View File

@ -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)