From 04f4d5053e2181ba70731ce2d253af208dadc7f1 Mon Sep 17 00:00:00 2001 From: Ricardo Marques Date: Mon, 11 Jun 2018 10:29:08 +0100 Subject: [PATCH] mgr/dashboard: SAML 2.0 support Fixes: https://tracker.ceph.com/issues/24268 Signed-off-by: Ricardo Dias Signed-off-by: Ricardo Marques --- doc/mgr/dashboard.rst | 47 +++ install-deps.sh | 3 + qa/tasks/mgr/dashboard/helper.py | 2 +- qa/tasks/mgr/dashboard/test_auth.py | 15 +- .../mgr/dashboard/controllers/__init__.py | 42 ++- src/pybind/mgr/dashboard/controllers/auth.py | 47 ++- src/pybind/mgr/dashboard/controllers/saml2.py | 115 ++++++++ .../frontend/src/app/app-routing.module.ts | 1 + .../app/core/auth/login/login.component.html | 3 +- .../app/core/auth/login/login.component.ts | 19 ++ .../user-form/user-form.component.spec.ts | 6 +- .../auth/user-form/user-form.component.ts | 1 - .../navigation/identity/identity.component.ts | 11 +- .../src/app/shared/api/auth.service.spec.ts | 15 +- .../src/app/shared/api/auth.service.ts | 17 +- .../services/api-interceptor.service.ts | 1 + src/pybind/mgr/dashboard/module.py | 25 +- .../mgr/dashboard/requirements-py27.txt | 1 + src/pybind/mgr/dashboard/requirements-py3.txt | 1 + src/pybind/mgr/dashboard/services/sso.py | 269 ++++++++++++++++++ src/pybind/mgr/dashboard/tests/__init__.py | 22 ++ .../dashboard/tests/test_access_control.py | 17 +- src/pybind/mgr/dashboard/tests/test_sso.py | 175 ++++++++++++ src/pybind/mgr/dashboard/tools.py | 13 + src/pybind/mgr/dashboard/tox.ini | 2 + 25 files changed, 805 insertions(+), 65 deletions(-) create mode 100644 src/pybind/mgr/dashboard/controllers/saml2.py create mode 100644 src/pybind/mgr/dashboard/requirements-py27.txt create mode 100644 src/pybind/mgr/dashboard/requirements-py3.txt create mode 100644 src/pybind/mgr/dashboard/services/sso.py create mode 100644 src/pybind/mgr/dashboard/tests/test_sso.py diff --git a/doc/mgr/dashboard.rst b/doc/mgr/dashboard.rst index ac21ab42200..5d938169229 100644 --- a/doc/mgr/dashboard.rst +++ b/doc/mgr/dashboard.rst @@ -319,6 +319,53 @@ You need to tell the dashboard on which url Grafana instance is running/deployed The format of url is : `::` You can directly access Grafana Instance as well to monitor your cluster. +Enabling Single Sign-On (SSO) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The Ceph Manager Dashboard supports external authentication of users via the +`SAML 2.0 `_ protocol. You need to create +the user accounts and associate them with the desired roles first, as authorization +is still performed by the Dashboard. However, the authentication process can be +performed by an existing Identity Provider (IdP). + +.. note:: + Ceph Dashboard SSO support relies on onelogin's + `python-saml `_ library. + Please ensure that this library is installed on your system, either by using + your distribution's package management or via Python's `pip` installer. + +To configure SSO on Ceph Dashboard, you should use the following command:: + + $ ceph dashboard sso setup saml2 {} {} {} {} + +Parameters: + +- ****: Base URL where Ceph Dashboard is accessible (e.g., `https://cephdashboard.local`) +- ****: URL, file path or content of the IdP metadata XML (e.g., `https://myidp/metadata`) +- **** *(optional)*: Attribute that should be used to get the username from the authentication response. Defaults to `uid`. +- **** *(optional)*: Use this when more than one entity id exists on the IdP metadata. +- ** / ** *(optional)*: File path or content of the certificate that should be used by Ceph Dashboard (Service Provider) for signing and encryption. + + +To display the current SAML 2.0 configuration, use the following command:: + + $ ceph dashboard sso show saml2 + +.. note:: + For more information about `onelogin_settings`, please check the `onelogin documentation `_. + +To disable SSO:: + + $ ceph dashboard sso disable + +To check if SSO is enabled:: + + $ ceph dashboard sso status + +To enable SSO:: + + $ ceph dashboard sso enable saml2 + Accessing the dashboard ^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/install-deps.sh b/install-deps.sh index f0e5ead71b4..7baefd30e41 100755 --- a/install-deps.sh +++ b/install-deps.sh @@ -270,6 +270,7 @@ else $SUDO env DEBIAN_FRONTEND=noninteractive apt-get -y remove ceph-build-deps install_seastar_deps if [ -n "$backports" ] ; then rm $control; fi + $SUDO apt-get install -y libxmlsec1 libxmlsec1-nss libxmlsec1-openssl libxmlsec1-dev ;; centos|fedora|rhel|ol|virtuozzo) yumdnf="yum" @@ -329,6 +330,7 @@ else ensure_decent_gcc_on_rh $dts_ver fi ! grep -q -i error: $DIR/yum-builddep.out || exit 1 + $SUDO $yumdnf install -y xmlsec1 xmlsec1-nss xmlsec1-openssl xmlsec1-devel xmlsec1-openssl-devel ;; opensuse*|suse|sles) echo "Using zypper to install dependencies" @@ -336,6 +338,7 @@ else $SUDO $zypp_install systemd-rpm-macros munge_ceph_spec_in $DIR/ceph.spec $SUDO $zypp_install $(rpmspec -q --buildrequires $DIR/ceph.spec) || exit 1 + $SUDO $zypp_install libxmlsec1-1 libxmlsec1-nss1 libxmlsec1-openssl1 xmlsec1-devel xmlsec1-openssl-devel ;; alpine) # for now we need the testing repo for leveldb diff --git a/qa/tasks/mgr/dashboard/helper.py b/qa/tasks/mgr/dashboard/helper.py index 08e8783b40c..4e85b75a821 100644 --- a/qa/tasks/mgr/dashboard/helper.py +++ b/qa/tasks/mgr/dashboard/helper.py @@ -78,7 +78,7 @@ class DashboardTestCase(MgrTestCase): @classmethod def logout(cls): if cls._loggedin: - cls._delete('/api/auth') + cls._post('/api/auth/logout') cls._token = None cls._loggedin = False diff --git a/qa/tasks/mgr/dashboard/test_auth.py b/qa/tasks/mgr/dashboard/test_auth.py index 0b6ab0ab521..0de3f278120 100644 --- a/qa/tasks/mgr/dashboard/test_auth.py +++ b/qa/tasks/mgr/dashboard/test_auth.py @@ -68,8 +68,11 @@ class AuthTest(DashboardTestCase): 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._post("/api/auth/logout") + self.assertStatus(200) + self.assertJsonBody({ + "redirect_url": "#/login" + }) self._get("/api/host") self.assertStatus(401) self.set_jwt_token(None) @@ -93,8 +96,8 @@ class AuthTest(DashboardTestCase): 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._post("/api/auth/logout") + self.assertStatus(200) self._get("/api/host") self.assertStatus(401) time.sleep(6) @@ -104,8 +107,8 @@ class AuthTest(DashboardTestCase): 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) + self._post("/api/auth/logout") + self.assertStatus(200) def test_unauthorized(self): self._get("/api/host") diff --git a/src/pybind/mgr/dashboard/controllers/__init__.py b/src/pybind/mgr/dashboard/controllers/__init__.py index 4aaf84ea72a..ce2b40a4452 100644 --- a/src/pybind/mgr/dashboard/controllers/__init__.py +++ b/src/pybind/mgr/dashboard/controllers/__init__.py @@ -83,7 +83,7 @@ class UiApiController(Controller): def Endpoint(method=None, path=None, path_params=None, query_params=None, - json_response=True, proxy=False): + json_response=True, proxy=False, xml=False): if method is None: method = 'GET' @@ -133,7 +133,8 @@ def Endpoint(method=None, path=None, path_params=None, query_params=None, 'path_params': path_params, 'query_params': query_params, 'json_response': json_response, - 'proxy': proxy + 'proxy': proxy, + 'xml': xml } return func return _wrapper @@ -374,7 +375,8 @@ class BaseController(object): @property def function(self): return self.ctrl._request_wrapper(self.func, self.method, - self.config['json_response']) + self.config['json_response'], + self.config['xml']) @property def method(self): @@ -517,7 +519,7 @@ class BaseController(object): return result @staticmethod - def _request_wrapper(func, method, json_response): # pylint: disable=unused-argument + def _request_wrapper(func, method, json_response, xml): # pylint: disable=unused-argument @wraps(func) def inner(*args, **kwargs): for key, value in kwargs.items(): @@ -533,12 +535,44 @@ class BaseController(object): ret = func(*args, **kwargs) if isinstance(ret, bytes): ret = ret.decode('utf-8') + if xml: + cherrypy.response.headers['Content-Type'] = 'application/xml' + return ret.encode('utf8') if json_response: cherrypy.response.headers['Content-Type'] = 'application/json' ret = json.dumps(ret).encode('utf8') return ret return inner + @property + def _request(self): + return self.Request(cherrypy.request) + + class Request(object): + def __init__(self, cherrypy_req): + self._creq = cherrypy_req + + @property + def scheme(self): + return self._creq.scheme + + @property + def host(self): + base = self._creq.base + base = base[len(self.scheme)+3:] + return base[:base.find(":")] if ":" in base else base + + @property + def port(self): + base = self._creq.base + base = base[len(self.scheme)+3:] + default_port = 443 if self.scheme == 'https' else 80 + return int(base[base.find(":")+1:]) if ":" in base else default_port + + @property + def path_info(self): + return self._creq.path_info + class RESTController(BaseController): """ diff --git a/src/pybind/mgr/dashboard/controllers/auth.py b/src/pybind/mgr/dashboard/controllers/auth.py index 9c0effd48f7..d1c6d7943ed 100644 --- a/src/pybind/mgr/dashboard/controllers/auth.py +++ b/src/pybind/mgr/dashboard/controllers/auth.py @@ -2,11 +2,14 @@ from __future__ import absolute_import import cherrypy +import jwt from . import ApiController, RESTController from .. import logger from ..exceptions import DashboardException from ..services.auth import AuthManager, JwtManager +from ..services.access_control import UserDoesNotExist +from ..services.sso import SSO_DB @ApiController('/auth', secure=False) @@ -34,6 +37,48 @@ class Auth(RESTController): code='invalid_credentials', component='auth') - def bulk_delete(self): + @RESTController.Collection('POST') + def logout(self): + logger.debug('Logout successful') token = JwtManager.get_token_from_header() JwtManager.blacklist_token(token) + redirect_url = '#/login' + if SSO_DB.protocol == 'saml2': + redirect_url = 'auth/saml2/slo' + return { + 'redirect_url': redirect_url + } + + def _get_login_url(self): + if SSO_DB.protocol == 'saml2': + return 'auth/saml2/login' + return '#/login' + + @RESTController.Collection('POST') + def check(self, 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']: + return { + 'username': user.username, + 'permissions': user.permissions_dict(), + } + + 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']) + return { + 'login_url': self._get_login_url() + } diff --git a/src/pybind/mgr/dashboard/controllers/saml2.py b/src/pybind/mgr/dashboard/controllers/saml2.py new file mode 100644 index 00000000000..0cc55367539 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/saml2.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +import sys +import cherrypy + +try: + from onelogin.saml2.auth import OneLogin_Saml2_Auth + from onelogin.saml2.errors import OneLogin_Saml2_Error + from onelogin.saml2.settings import OneLogin_Saml2_Settings + + python_saml_imported = True +except ImportError: + python_saml_imported = False + +from .. import mgr, logger +from ..exceptions import UserDoesNotExist +from ..services.auth import JwtManager +from ..services.access_control import ACCESS_CTRL_DB +from ..services.sso import SSO_DB +from ..tools import prepare_url_prefix +from . import Controller, Endpoint, BaseController + + +@Controller('/auth/saml2', secure=False) +class Saml2(BaseController): + + @staticmethod + def _build_req(request, post_data): + return { + 'https': 'on' if request.scheme == 'https' else 'off', + 'http_host': request.host, + 'script_name': request.path_info, + 'server_port': str(request.port), + 'get_data': {}, + 'post_data': post_data + } + + @staticmethod + def _check_python_saml(): + if not python_saml_imported: + python_saml_name = 'python3-saml' if sys.version_info >= (3, 0) else 'python-saml' + raise cherrypy.HTTPError(400, + 'Required library not found: `{}`'.format(python_saml_name)) + try: + OneLogin_Saml2_Settings(SSO_DB.saml2.onelogin_settings) + except OneLogin_Saml2_Error: + raise cherrypy.HTTPError(400, 'Single Sign-On is not configured.') + + @Endpoint('POST', path="") + def auth_response(self, **kwargs): + Saml2._check_python_saml() + req = Saml2._build_req(self._request, kwargs) + auth = OneLogin_Saml2_Auth(req, SSO_DB.saml2.onelogin_settings) + auth.process_response() + errors = auth.get_errors() + + if auth.is_authenticated(): + JwtManager.reset_user() + username_attribute = auth.get_attribute(SSO_DB.saml2.get_username_attribute()) + if username_attribute is None: + raise cherrypy.HTTPError(400, + 'SSO error - `{}` not found in auth attributes. ' + 'Received attributes: {}' + .format( + SSO_DB.saml2.get_username_attribute(), + auth.get_attributes())) + username = username_attribute[0] + try: + ACCESS_CTRL_DB.get_user(username) + except UserDoesNotExist: + raise cherrypy.HTTPError(400, + 'SSO error - Username `{}` does not exist.' + .format(username)) + + token = JwtManager.gen_token(username) + JwtManager.set_user(JwtManager.decode_token(token)) + token = token.decode('utf-8') + logger.debug("JWT Token: %s", token) + url_prefix = prepare_url_prefix(mgr.get_config('url_prefix', default='')) + raise cherrypy.HTTPRedirect("{}/#/login?access_token={}".format(url_prefix, token)) + else: + return { + 'is_authenticated': auth.is_authenticated(), + 'errors': errors, + 'reason': auth.get_last_error_reason() + } + + @Endpoint(xml=True) + def metadata(self): + Saml2._check_python_saml() + saml_settings = OneLogin_Saml2_Settings(SSO_DB.saml2.onelogin_settings) + return saml_settings.get_sp_metadata() + + @Endpoint(json_response=False) + def login(self): + Saml2._check_python_saml() + req = Saml2._build_req(self._request, {}) + auth = OneLogin_Saml2_Auth(req, SSO_DB.saml2.onelogin_settings) + raise cherrypy.HTTPRedirect(auth.login()) + + @Endpoint(json_response=False) + def slo(self): + Saml2._check_python_saml() + req = Saml2._build_req(self._request, {}) + auth = OneLogin_Saml2_Auth(req, SSO_DB.saml2.onelogin_settings) + raise cherrypy.HTTPRedirect(auth.logout()) + + @Endpoint(json_response=False) + def logout(self, **kwargs): + # pylint: disable=unused-argument + Saml2._check_python_saml() + JwtManager.reset_user() + url_prefix = prepare_url_prefix(mgr.get_config('url_prefix', default='')) + raise cherrypy.HTTPRedirect("{}/#/login".format(url_prefix)) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 79e7b5fa601..5164c4b76ac 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -244,6 +244,7 @@ const routes: Routes = [ }, // System { path: 'login', component: LoginComponent }, + { path: 'logout', children: [] }, { path: '403', component: ForbiddenComponent }, { path: '404', component: NotFoundComponent }, { path: '**', redirectTo: '/404' } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html index a3d1d19af32..5734d43f594 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html @@ -1,4 +1,5 @@ -