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
^^^^^^^^^^^^^^^^^^^^^^^
$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"
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"
$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
@classmethod
def logout(cls):
if cls._loggedin:
- cls._delete('/api/auth')
+ cls._post('/api/auth/logout')
cls._token = None
cls._loggedin = False
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)
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)
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")
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'
'path_params': path_params,
'query_params': query_params,
'json_response': json_response,
- 'proxy': proxy
+ 'proxy': proxy,
+ 'xml': xml
}
return func
return _wrapper
@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):
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():
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):
"""
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)
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()
+ }
--- /dev/null
+# -*- 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))
},
// System
{ path: 'login', component: LoginComponent },
+ { path: 'logout', children: [] },
{ path: '403', component: ForbiddenComponent },
{ path: '404', component: NotFoundComponent },
{ path: '**', redirectTo: '/404' }
-<div class="login">
+<div class="login"
+ *ngIf="isLoginActive">
<div class="row full-height vertical-align">
<div class="col-sm-6 hidden-xs">
<img src="assets/Ceph_Logo_Stacked_RGB_White_120411_fa_256x256.png"
})
export class LoginComponent implements OnInit {
model = new Credentials();
+ isLoginActive = false;
constructor(
private authService: AuthService,
for (let i = 1; i <= modalsCount; i++) {
this.bsModalService.hide(i);
}
+ let token = null;
+ if (window.location.hash.indexOf('access_token=') !== -1) {
+ token = window.location.hash.split('access_token=')[1];
+ const uri = window.location.toString();
+ window.history.replaceState({}, document.title, uri.split('?')[0]);
+ }
+ this.authService.check(token).subscribe((login: any) => {
+ if (login.login_url) {
+ if (login.login_url === '#/login') {
+ this.isLoginActive = true;
+ } else {
+ window.location.replace(login.login_url);
+ }
+ } else {
+ this.authStorageService.set(login.username, token, login.permissions);
+ this.router.navigate(['']);
+ }
+ });
}
}
const userReq = httpTesting.expectOne(`api/user/${user.username}`);
expect(userReq.request.method).toBe('PUT');
userReq.flush({});
- const authReq = httpTesting.expectOne('api/auth');
- expect(authReq.request.method).toBe('DELETE');
- authReq.flush(null);
- expect(router.navigate).toHaveBeenCalledWith(['/login']);
+ const authReq = httpTesting.expectOne('api/auth/logout');
+ expect(authReq.request.method).toBe('POST');
});
it('should submit', () => {
NotificationType.info,
'You were automatically logged out because your roles have been changed.'
);
- this.router.navigate(['/login']);
});
} else {
this.notificationService.show(
import { Component, OnInit } from '@angular/core';
-import { Router } from '@angular/router';
import { AuthService } from '../../../shared/api/auth.service';
import { AuthStorageService } from '../../../shared/services/auth-storage.service';
export class IdentityComponent implements OnInit {
username: string;
- constructor(
- private router: Router,
- private authStorageService: AuthStorageService,
- private authService: AuthService
- ) {}
+ constructor(private authStorageService: AuthStorageService, private authService: AuthService) {}
ngOnInit() {
this.username = this.authStorageService.getUsername();
}
logout() {
- this.authService.logout(() => {
- this.router.navigate(['/login']);
- });
+ this.authService.logout();
}
}
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
-
-import { AuthStorageService } from '../services/auth-storage.service';
+import { Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
import { configureTestBed } from '../../../testing/unit-test-helper';
+import { AuthStorageService } from '../services/auth-storage.service';
import { AuthService } from './auth.service';
describe('AuthService', () => {
let service: AuthService;
let httpTesting: HttpTestingController;
+ const routes: Routes = [{ path: 'logout', children: [] }];
+
configureTestBed({
providers: [AuthService, AuthStorageService],
- imports: [HttpClientTestingModule]
+ imports: [HttpClientTestingModule, RouterTestingModule.withRoutes(routes)]
});
beforeEach(() => {
it('should logout and remove the user', fakeAsync(() => {
service.logout();
- const req = httpTesting.expectOne('api/auth');
- expect(req.request.method).toBe('DELETE');
- req.flush({ username: 'foo' });
+ const req = httpTesting.expectOne('api/auth/logout');
+ expect(req.request.method).toBe('POST');
+ req.flush({ redirect_url: '#/login' });
tick();
expect(localStorage.getItem('dashboard_username')).toBe(null);
}));
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
+import { Router } from '@angular/router';
import { Credentials } from '../models/credentials';
import { LoginResponse } from '../models/login-response';
providedIn: ApiModule
})
export class AuthService {
- constructor(private authStorageService: AuthStorageService, private http: HttpClient) {}
+ constructor(
+ private authStorageService: AuthStorageService,
+ private http: HttpClient,
+ private router: Router
+ ) {}
+
+ check(token: string) {
+ return this.http.post('api/auth/check', { token: token });
+ }
login(credentials: Credentials) {
return this.http
});
}
- logout(callback: Function) {
- return this.http.delete('api/auth').subscribe(() => {
+ logout(callback: Function = null) {
+ return this.http.post('api/auth/logout', null).subscribe((resp: any) => {
+ this.router.navigate(['/logout'], { skipLocationChange: true });
this.authStorageService.remove();
if (callback) {
callback();
}
+ window.location.replace(resp.redirect_url);
});
}
}
case 401:
this.authStorageService.remove();
this.router.navigate(['/login']);
+ showNotification = false;
break;
case 403:
this.router.navigate(['/403']);
from mgr_module import MgrModule, MgrStandbyModule
-try:
- from urlparse import urljoin
-except ImportError:
- from urllib.parse import urljoin
-
try:
import cherrypy
from cherrypy._cptools import HandlerWrapperTool
# To be picked up and reported by .can_run()
cherrypy = None
+from .services.sso import load_sso_db
# The SSL code in CherryPy 3.5.0 is buggy. It was fixed long ago,
# but 3.5.0 is still shipping in major linux distributions
# pylint: disable=wrong-import-position
from . import logger, mgr
from .controllers import generate_routes, json_error_page
-from .tools import NotificationQueue, RequestLoggingTool, TaskManager
+from .tools import NotificationQueue, RequestLoggingTool, TaskManager, \
+ prepare_url_prefix
from .services.auth import AuthManager, AuthManagerTool, JwtManager
from .services.access_control import ACCESS_CONTROL_COMMANDS, \
handle_access_control_command
+from .services.sso import SSO_COMMANDS, \
+ handle_sso_command
from .services.exception import dashboard_exception_handler
from .settings import options_command_list, options_schema_list, \
handle_option_command
os._exit = os_exit_noop
-def prepare_url_prefix(url_prefix):
- """
- return '' if no prefix, or '/prefix' without slash in the end.
- """
- url_prefix = urljoin('/', url_prefix)
- return url_prefix.rstrip('/')
-
-
class ServerConfigException(Exception):
pass
]
COMMANDS.extend(options_command_list())
COMMANDS.extend(ACCESS_CONTROL_COMMANDS)
+ COMMANDS.extend(SSO_COMMANDS)
OPTIONS = [
{'name': 'server_addr'},
_cov.start()
AuthManager.initialize()
+ load_sso_db()
uri = self.await_configuration()
if uri is None:
self.shutdown_event.set()
def handle_command(self, inbuf, cmd):
+ # pylint: disable=too-many-return-statements
res = handle_option_command(cmd)
if res[0] != -errno.ENOSYS:
return res
res = handle_access_control_command(cmd)
+ if res[0] != -errno.ENOSYS:
+ return res
+ res = handle_sso_command(cmd)
if res[0] != -errno.ENOSYS:
return res
elif cmd['prefix'] == 'dashboard set-jwt-token-ttl':
--- /dev/null
+python-saml==2.4.2
--- /dev/null
+python3-saml==1.4.1
--- /dev/null
+# -*- coding: utf-8 -*-
+# pylint: disable=too-many-return-statements,too-many-branches
+from __future__ import absolute_import
+
+import errno
+import json
+import sys
+import threading
+
+try:
+ from onelogin.saml2.settings import OneLogin_Saml2_Settings
+ from onelogin.saml2.errors import OneLogin_Saml2_Error
+ from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
+
+ python_saml_imported = True
+except ImportError:
+ python_saml_imported = False
+
+
+from .. import mgr, logger
+from ..tools import prepare_url_prefix
+
+
+class Saml2(object):
+ def __init__(self, onelogin_settings):
+ self.onelogin_settings = onelogin_settings
+
+ def get_username_attribute(self):
+ return self.onelogin_settings['sp']['attributeConsumingService']['requestedAttributes'][0][
+ 'name']
+
+ def to_dict(self):
+ return {
+ 'onelogin_settings': self.onelogin_settings
+ }
+
+ @classmethod
+ def from_dict(cls, s_dict):
+ return Saml2(s_dict['onelogin_settings'])
+
+
+class SsoDB(object):
+ VERSION = 1
+ SSODB_CONFIG_KEY = "ssodb_v"
+
+ def __init__(self, version, protocol, saml2):
+ self.version = version
+ self.protocol = protocol
+ self.saml2 = saml2
+ self.lock = threading.RLock()
+
+ def save(self):
+ with self.lock:
+ db = {
+ 'protocol': self.protocol,
+ 'saml2': self.saml2.to_dict(),
+ 'version': self.version
+ }
+ mgr.set_store(self.ssodb_config_key(), json.dumps(db))
+
+ @classmethod
+ def ssodb_config_key(cls, version=None):
+ if version is None:
+ version = cls.VERSION
+ return "{}{}".format(cls.SSODB_CONFIG_KEY, version)
+
+ def check_and_update_db(self):
+ logger.debug("SSO: Checking for previews DB versions")
+ if self.VERSION != 1:
+ raise NotImplementedError()
+
+ @classmethod
+ def load(cls):
+ logger.info("SSO: Loading SSO DB version=%s", cls.VERSION)
+
+ json_db = mgr.get_store(cls.ssodb_config_key(), None)
+ if json_db is None:
+ logger.debug("SSO: No DB v%s found, creating new...", cls.VERSION)
+ db = cls(cls.VERSION, '', Saml2({}))
+ # check if we can update from a previous version database
+ db.check_and_update_db()
+ return db
+
+ db = json.loads(json_db)
+ return cls(db['version'], db.get('protocol'), Saml2.from_dict(db.get('saml2')))
+
+
+SSO_DB = None
+
+
+def load_sso_db():
+ # pylint: disable=W0603
+ global SSO_DB
+ SSO_DB = SsoDB.load()
+
+
+SSO_COMMANDS = [
+ {
+ 'cmd': 'dashboard sso enable saml2',
+ 'desc': 'Enable SAML2 Single Sign-On',
+ 'perm': 'w'
+ },
+ {
+ 'cmd': 'dashboard sso disable',
+ 'desc': 'Disable Single Sign-On',
+ 'perm': 'w'
+ },
+ {
+ 'cmd': 'dashboard sso status',
+ 'desc': 'Get Single Sign-On status',
+ 'perm': 'r'
+ },
+ {
+ 'cmd': 'dashboard sso show saml2',
+ 'desc': 'Show SAML2 configuration',
+ 'perm': 'r'
+ },
+ {
+ 'cmd': 'dashboard sso setup saml2 '
+ 'name=ceph_dashboard_base_url,type=CephString '
+ 'name=idp_metadata,type=CephString '
+ 'name=idp_username_attribute,type=CephString,req=false '
+ 'name=idp_entity_id,type=CephString,req=false '
+ 'name=sp_x_509_cert,type=CephString,req=false '
+ 'name=sp_private_key,type=CephString,req=false',
+ 'desc': 'Setup SAML2 Single Sign-On',
+ 'perm': 'w'
+ }
+]
+
+
+def _get_optional_attr(cmd, attr, default):
+ if attr in cmd:
+ if cmd[attr] != '':
+ return cmd[attr]
+ return default
+
+
+def handle_sso_command(cmd):
+ if cmd['prefix'] not in ['dashboard sso enable saml2',
+ 'dashboard sso disable',
+ 'dashboard sso status',
+ 'dashboard sso show saml2',
+ 'dashboard sso setup saml2']:
+ return -errno.ENOSYS, '', ''
+
+ if not python_saml_imported:
+ python_saml_name = 'python3-saml' if sys.version_info >= (3, 0) else 'python-saml'
+ return -errno.EPERM, '', 'Required library not found: `{}`'.format(python_saml_name)
+
+ if cmd['prefix'] == 'dashboard sso enable saml2':
+ try:
+ OneLogin_Saml2_Settings(SSO_DB.saml2.onelogin_settings)
+ except OneLogin_Saml2_Error:
+ return -errno.EPERM, '', 'Single Sign-On is not configured: ' \
+ 'use `ceph dashboard sso setup saml2`'
+ SSO_DB.protocol = 'saml2'
+ SSO_DB.save()
+ return 0, 'SSO is "enabled" with "SAML2" protocol.', ''
+
+ if cmd['prefix'] == 'dashboard sso disable':
+ SSO_DB.protocol = ''
+ SSO_DB.save()
+ return 0, 'SSO is "disabled".', ''
+
+ if cmd['prefix'] == 'dashboard sso status':
+ if SSO_DB.protocol == 'saml2':
+ return 0, 'SSO is "enabled" with "SAML2" protocol.', ''
+
+ return 0, 'SSO is "disabled".', ''
+
+ if cmd['prefix'] == 'dashboard sso show saml2':
+ return 0, json.dumps(SSO_DB.saml2.to_dict()), ''
+
+ if cmd['prefix'] == 'dashboard sso setup saml2':
+ ceph_dashboard_base_url = cmd['ceph_dashboard_base_url']
+ idp_metadata = cmd['idp_metadata']
+ idp_username_attribute = _get_optional_attr(cmd, 'idp_username_attribute', 'uid')
+ idp_entity_id = _get_optional_attr(cmd, 'idp_entity_id', None)
+ sp_x_509_cert = _get_optional_attr(cmd, 'sp_x_509_cert', '')
+ sp_private_key = _get_optional_attr(cmd, 'sp_private_key', '')
+ if sp_x_509_cert and not sp_private_key:
+ return -errno.EINVAL, '', 'Missing parameter `sp_private_key`.'
+ if not sp_x_509_cert and sp_private_key:
+ return -errno.EINVAL, '', 'Missing parameter `sp_x_509_cert`.'
+ has_sp_cert = sp_x_509_cert != "" and sp_private_key != ""
+ try:
+ # pylint: disable=undefined-variable
+ FileNotFoundError
+ except NameError:
+ # pylint: disable=redefined-builtin
+ FileNotFoundError = IOError
+ try:
+ f = open(sp_x_509_cert, 'r')
+ sp_x_509_cert = f.read()
+ f.close()
+ except FileNotFoundError:
+ pass
+ try:
+ f = open(sp_private_key, 'r')
+ sp_private_key = f.read()
+ f.close()
+ except FileNotFoundError:
+ pass
+ try:
+ idp_settings = OneLogin_Saml2_IdPMetadataParser.parse_remote(idp_metadata,
+ validate_cert=False,
+ entity_id=idp_entity_id)
+ # pylint: disable=broad-except
+ except Exception:
+ try:
+ f = open(idp_metadata, 'r')
+ idp_metadata = f.read()
+ f.close()
+ except FileNotFoundError:
+ pass
+ try:
+ idp_settings = OneLogin_Saml2_IdPMetadataParser.parse(idp_metadata,
+ entity_id=idp_entity_id)
+ # pylint: disable=broad-except
+ except Exception:
+ return -errno.EINVAL, '', 'Invalid parameter `idp_metadata`.'
+
+ url_prefix = prepare_url_prefix(mgr.get_config('url_prefix', default=''))
+ settings = {
+ 'sp': {
+ 'entityId': '{}{}/auth/saml2/metadata'.format(ceph_dashboard_base_url, url_prefix),
+ 'assertionConsumerService': {
+ 'url': '{}{}/auth/saml2'.format(ceph_dashboard_base_url, url_prefix),
+ 'binding': "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+ },
+ 'attributeConsumingService': {
+ 'serviceName': "Ceph Dashboard",
+ "serviceDescription": "Ceph Dashboard Service",
+ "requestedAttributes": [
+ {
+ "name": idp_username_attribute,
+ "isRequired": True
+ }
+ ]
+ },
+ 'singleLogoutService': {
+ 'url': '{}{}/auth/saml2/logout'.format(ceph_dashboard_base_url, url_prefix),
+ 'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
+ },
+ "x509cert": sp_x_509_cert,
+ "privateKey": sp_private_key
+ },
+ 'security': {
+ "nameIdEncrypted": has_sp_cert,
+ "authnRequestsSigned": has_sp_cert,
+ "logoutRequestSigned": has_sp_cert,
+ "logoutResponseSigned": has_sp_cert,
+ "signMetadata": has_sp_cert,
+ "wantMessagesSigned": has_sp_cert,
+ "wantAssertionsSigned": has_sp_cert,
+ "wantAssertionsEncrypted": has_sp_cert,
+ "wantNameIdEncrypted": has_sp_cert,
+ "metadataValidUntil": '',
+ "wantAttributeStatement": False
+ }
+ }
+ settings = OneLogin_Saml2_IdPMetadataParser.merge_settings(settings, idp_settings)
+ SSO_DB.saml2.onelogin_settings = settings
+ SSO_DB.protocol = 'saml2'
+ SSO_DB.save()
+ return 0, json.dumps(SSO_DB.saml2.onelogin_settings), ''
+
+ return -errno.ENOSYS, '', ''
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import json
+
+
+class CmdException(Exception):
+ def __init__(self, retcode, message):
+ super(CmdException, self).__init__(message)
+ self.retcode = retcode
+
+
+def exec_dashboard_cmd(command_handler, cmd, **kwargs):
+ cmd_dict = {'prefix': 'dashboard {}'.format(cmd)}
+ cmd_dict.update(kwargs)
+ ret, out, err = command_handler(cmd_dict)
+ if ret < 0:
+ raise CmdException(ret, err)
+ try:
+ return json.loads(out)
+ except ValueError:
+ return out
import time
import unittest
+from . import CmdException, exec_dashboard_cmd
from .. import mgr
from ..security import Scope, Permission
from ..services.access_control import handle_access_control_command, \
SYSTEM_ROLES
-class CmdException(Exception):
- def __init__(self, retcode, message):
- super(CmdException, self).__init__(message)
- self.retcode = retcode
-
-
class AccessControlTest(unittest.TestCase):
CONFIG_KEY_DICT = {}
@classmethod
def exec_cmd(cls, cmd, **kwargs):
- cmd_dict = {'prefix': 'dashboard {}'.format(cmd)}
- cmd_dict.update(kwargs)
- ret, out, err = handle_access_control_command(cmd_dict)
- if ret < 0:
- raise CmdException(ret, err)
- try:
- return json.loads(out)
- except ValueError:
- return out
+ return exec_dashboard_cmd(handle_access_control_command, cmd, **kwargs)
def load_persistent_db(self):
config_key = AccessControlDB.accessdb_config_key()
--- /dev/null
+# -*- coding: utf-8 -*-
+# pylint: disable=dangerous-default-value,too-many-public-methods
+from __future__ import absolute_import
+
+import errno
+import unittest
+
+from . import CmdException, exec_dashboard_cmd
+from .. import mgr
+from ..services.sso import handle_sso_command, load_sso_db
+
+
+class AccessControlTest(unittest.TestCase):
+ CONFIG_KEY_DICT = {}
+
+ @classmethod
+ def mock_set_config(cls, attr, val):
+ cls.CONFIG_KEY_DICT[attr] = val
+
+ @classmethod
+ def mock_get_config(cls, attr, default):
+ return cls.CONFIG_KEY_DICT.get(attr, default)
+
+ IDP_METADATA = '''<?xml version="1.0"?>
+<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
+ xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
+ entityID="https://testidp.ceph.com/simplesamlphp/saml2/idp/metadata.php"
+ ID="pfx8ca6fbd7-6062-d4a9-7995-0730aeb8114f">
+ <ds:Signature>
+ <ds:SignedInfo>
+ <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
+ <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
+ <ds:Reference URI="#pfx8ca6fbd7-6062-d4a9-7995-0730aeb8114f">
+ <ds:Transforms>
+ <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
+ <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
+ </ds:Transforms>
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
+ <ds:DigestValue>v6V8fooEUeq/LO/59JCfJF69Tw3ohN52OGAY6X3jX8w=</ds:DigestValue>
+ </ds:Reference>
+ </ds:SignedInfo>
+ <ds:SignatureValue>IDP_SIGNATURE_VALUE</ds:SignatureValue>
+ <ds:KeyInfo>
+ <ds:X509Data>
+ <ds:X509Certificate>IDP_X509_CERTIFICATE</ds:X509Certificate>
+ </ds:X509Data>
+ </ds:KeyInfo>
+ </ds:Signature>
+ <md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
+ <md:KeyDescriptor use="signing">
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+ <ds:X509Data>
+ <ds:X509Certificate>IDP_X509_CERTIFICATE</ds:X509Certificate>
+ </ds:X509Data>
+ </ds:KeyInfo>
+ </md:KeyDescriptor>
+ <md:KeyDescriptor use="encryption">
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+ <ds:X509Data>
+ <ds:X509Certificate>IDP_X509_CERTIFICATE</ds:X509Certificate>
+ </ds:X509Data>
+ </ds:KeyInfo>
+ </md:KeyDescriptor>
+ <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
+ Location="https://testidp.ceph.com/simplesamlphp/saml2/idp/SingleLogoutService.php"/>
+ <md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat>
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
+ Location="https://testidp.ceph.com/simplesamlphp/saml2/idp/SSOService.php"/>
+ </md:IDPSSODescriptor>
+</md:EntityDescriptor>'''
+
+ @classmethod
+ def setUpClass(cls):
+ mgr.set_config.side_effect = cls.mock_set_config
+ mgr.get_config.side_effect = cls.mock_get_config
+ mgr.set_store.side_effect = cls.mock_set_config
+ mgr.get_store.side_effect = cls.mock_get_config
+
+ def setUp(self):
+ self.CONFIG_KEY_DICT.clear()
+ load_sso_db()
+
+ @classmethod
+ def exec_cmd(cls, cmd, **kwargs):
+ return exec_dashboard_cmd(handle_sso_command, cmd, **kwargs)
+
+ def validate_onelogin_settings(self, onelogin_settings, ceph_dashboard_base_url, uid,
+ sp_x509cert, sp_private_key, signature_enabled):
+ self.assertIn('sp', onelogin_settings)
+ self.assertIn('entityId', onelogin_settings['sp'])
+ self.assertEqual(onelogin_settings['sp']['entityId'],
+ '{}/auth/saml2/metadata'.format(ceph_dashboard_base_url))
+
+ self.assertIn('assertionConsumerService', onelogin_settings['sp'])
+ self.assertIn('url', onelogin_settings['sp']['assertionConsumerService'])
+ self.assertEqual(onelogin_settings['sp']['assertionConsumerService']['url'],
+ '{}/auth/saml2'.format(ceph_dashboard_base_url))
+
+ self.assertIn('attributeConsumingService', onelogin_settings['sp'])
+ attribute_consuming_service = onelogin_settings['sp']['attributeConsumingService']
+ self.assertIn('requestedAttributes', attribute_consuming_service)
+ requested_attributes = attribute_consuming_service['requestedAttributes']
+ self.assertEqual(len(requested_attributes), 1)
+ self.assertIn('name', requested_attributes[0])
+ self.assertEqual(requested_attributes[0]['name'], uid)
+
+ self.assertIn('singleLogoutService', onelogin_settings['sp'])
+ self.assertIn('url', onelogin_settings['sp']['singleLogoutService'])
+ self.assertEqual(onelogin_settings['sp']['singleLogoutService']['url'],
+ '{}/auth/saml2/logout'.format(ceph_dashboard_base_url))
+
+ self.assertIn('x509cert', onelogin_settings['sp'])
+ self.assertEqual(onelogin_settings['sp']['x509cert'], sp_x509cert)
+
+ self.assertIn('privateKey', onelogin_settings['sp'])
+ self.assertEqual(onelogin_settings['sp']['privateKey'], sp_private_key)
+
+ self.assertIn('security', onelogin_settings)
+ self.assertIn('authnRequestsSigned', onelogin_settings['security'])
+ self.assertEqual(onelogin_settings['security']['authnRequestsSigned'], signature_enabled)
+
+ self.assertIn('logoutRequestSigned', onelogin_settings['security'])
+ self.assertEqual(onelogin_settings['security']['logoutRequestSigned'], signature_enabled)
+
+ self.assertIn('logoutResponseSigned', onelogin_settings['security'])
+ self.assertEqual(onelogin_settings['security']['logoutResponseSigned'], signature_enabled)
+
+ self.assertIn('wantMessagesSigned', onelogin_settings['security'])
+ self.assertEqual(onelogin_settings['security']['wantMessagesSigned'], signature_enabled)
+
+ self.assertIn('wantAssertionsSigned', onelogin_settings['security'])
+ self.assertEqual(onelogin_settings['security']['wantAssertionsSigned'], signature_enabled)
+
+ def test_sso_saml2_setup(self):
+ result = self.exec_cmd('sso setup saml2',
+ ceph_dashboard_base_url='https://cephdashboard.local',
+ idp_metadata=self.IDP_METADATA)
+ self.validate_onelogin_settings(result, 'https://cephdashboard.local', 'uid', '', '',
+ False)
+
+ def test_sso_enable_saml2(self):
+ with self.assertRaises(CmdException) as ctx:
+ self.exec_cmd('sso enable saml2')
+
+ self.assertEqual(ctx.exception.retcode, -errno.EPERM)
+ self.assertEqual(str(ctx.exception), 'Single Sign-On is not configured: '
+ 'use `ceph dashboard sso setup saml2`')
+
+ self.exec_cmd('sso setup saml2',
+ ceph_dashboard_base_url='https://cephdashboard.local',
+ idp_metadata=self.IDP_METADATA)
+
+ result = self.exec_cmd('sso enable saml2')
+ self.assertEqual(result, 'SSO is "enabled" with "SAML2" protocol.')
+
+ def test_sso_disable(self):
+ result = self.exec_cmd('sso disable')
+ self.assertEqual(result, 'SSO is "disabled".')
+
+ def test_sso_status(self):
+ result = self.exec_cmd('sso status')
+ self.assertEqual(result, 'SSO is "disabled".')
+
+ self.exec_cmd('sso setup saml2',
+ ceph_dashboard_base_url='https://cephdashboard.local',
+ idp_metadata=self.IDP_METADATA)
+
+ result = self.exec_cmd('sso status')
+ self.assertEqual(result, 'SSO is "enabled" with "SAML2" protocol.')
+
+ def test_sso_show_saml2(self):
+ result = self.exec_cmd('sso show saml2')
+ self.assertEqual(result, {
+ 'onelogin_settings': {}
+ })
from six.moves import urllib
import cherrypy
+try:
+ from urlparse import urljoin
+except ImportError:
+ from urllib.parse import urljoin
+
from . import logger, mgr
from .exceptions import ViewCacheNoDataException
from .settings import Settings
return pr.geturl()
+def prepare_url_prefix(url_prefix):
+ """
+ return '' if no prefix, or '/prefix' without slash in the end.
+ """
+ url_prefix = urljoin('/', url_prefix)
+ return url_prefix.rstrip('/')
+
+
def dict_contains_path(dct, keys):
"""
Tests whether the keys exist recursively in `dictionary`.
[testenv]
deps =
-r{toxinidir}/requirements.txt
+ py27: -r{toxinidir}/requirements-py27.txt
+ py3: -r{toxinidir}/requirements-py3.txt
setenv=
UNITTEST = true
WEBTEST_INTERACTIVE = false