Skip to content

Commit

Permalink
Merge pull request #24489 from ricardoasmarques/wip-saml2
Browse files Browse the repository at this point in the history
mgr/dashboard: SSO - SAML 2.0 support

Reviewed-by: Tatjana Dehler <tdehler@suse.com>
Reviewed-by: Tiago Melo <tmelo@suse.com>
Reviewed-by: Volker Theile <vtheile@suse.com>
  • Loading branch information
LenzGr committed Nov 9, 2018
2 parents 06fcb75 + 04f4d50 commit 3ba8740
Show file tree
Hide file tree
Showing 25 changed files with 805 additions and 65 deletions.
47 changes: 47 additions & 0 deletions doc/mgr/dashboard.rst
Expand Up @@ -319,6 +319,53 @@ You need to tell the dashboard on which url Grafana instance is running/deployed
The format of url is : `<protocol>:<IP-address>:<port>`
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 <https://en.wikipedia.org/wiki/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 <https://pypi.org/project/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 <ceph_dashboard_base_url> <idp_metadata> {<idp_username_attribute>} {<idp_entity_id>} {<sp_x_509_cert>} {<sp_private_key>}

Parameters:

- **<ceph_dashboard_base_url>**: Base URL where Ceph Dashboard is accessible (e.g., `https://cephdashboard.local`)
- **<idp_metadata>**: URL, file path or content of the IdP metadata XML (e.g., `https://myidp/metadata`)
- **<idp_username_attribute>** *(optional)*: Attribute that should be used to get the username from the authentication response. Defaults to `uid`.
- **<idp_entity_id>** *(optional)*: Use this when more than one entity id exists on the IdP metadata.
- **<sp_x_509_cert> / <sp_private_key>** *(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 <https://github.com/onelogin/python-saml>`_.

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

Expand Down
3 changes: 3 additions & 0 deletions install-deps.sh
Expand Up @@ -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"
Expand Down Expand Up @@ -329,13 +330,15 @@ 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"
zypp_install="zypper --gpg-auto-import-keys --non-interactive install --no-recommends"
$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
Expand Down
2 changes: 1 addition & 1 deletion qa/tasks/mgr/dashboard/helper.py
Expand Up @@ -78,7 +78,7 @@ def login(cls, username, password):
@classmethod
def logout(cls):
if cls._loggedin:
cls._delete('/api/auth')
cls._post('/api/auth/logout')
cls._token = None
cls._loggedin = False

Expand Down
15 changes: 9 additions & 6 deletions qa/tasks/mgr/dashboard/test_auth.py
Expand Up @@ -68,8 +68,11 @@ def test_logout(self):
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)
Expand All @@ -93,8 +96,8 @@ def test_remove_from_blacklist(self):
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)
Expand All @@ -104,8 +107,8 @@ def test_remove_from_blacklist(self):
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")
Expand Down
42 changes: 38 additions & 4 deletions src/pybind/mgr/dashboard/controllers/__init__.py
Expand Up @@ -83,7 +83,7 @@ def __init__(self, path, security_scope=None, secure=True):


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'
Expand Down Expand Up @@ -133,7 +133,8 @@ def _wrapper(func):
'path_params': path_params,
'query_params': query_params,
'json_response': json_response,
'proxy': proxy
'proxy': proxy,
'xml': xml
}
return func
return _wrapper
Expand Down Expand Up @@ -374,7 +375,8 @@ def config(self):
@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):
Expand Down Expand Up @@ -517,7 +519,7 @@ def endpoints(cls):
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():
Expand All @@ -533,12 +535,44 @@ def inner(*args, **kwargs):
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):
"""
Expand Down
47 changes: 46 additions & 1 deletion src/pybind/mgr/dashboard/controllers/auth.py
Expand Up @@ -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)
Expand Down Expand Up @@ -34,6 +37,48 @@ def create(self, username, password):
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()
}
115 changes: 115 additions & 0 deletions 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))
Expand Up @@ -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' }
Expand Down

0 comments on commit 3ba8740

Please sign in to comment.