]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: SAML 2.0 support
authorRicardo Marques <rimarques@suse.com>
Mon, 11 Jun 2018 09:29:08 +0000 (10:29 +0100)
committerRicardo Marques <rimarques@suse.com>
Thu, 8 Nov 2018 15:27:37 +0000 (15:27 +0000)
Fixes: https://tracker.ceph.com/issues/24268
Signed-off-by: Ricardo Dias <rdias@suse.com>
Signed-off-by: Ricardo Marques <rimarques@suse.com>
25 files changed:
doc/mgr/dashboard.rst
install-deps.sh
qa/tasks/mgr/dashboard/helper.py
qa/tasks/mgr/dashboard/test_auth.py
src/pybind/mgr/dashboard/controllers/__init__.py
src/pybind/mgr/dashboard/controllers/auth.py
src/pybind/mgr/dashboard/controllers/saml2.py [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts
src/pybind/mgr/dashboard/module.py
src/pybind/mgr/dashboard/requirements-py27.txt [new file with mode: 0644]
src/pybind/mgr/dashboard/requirements-py3.txt [new file with mode: 0644]
src/pybind/mgr/dashboard/services/sso.py [new file with mode: 0644]
src/pybind/mgr/dashboard/tests/__init__.py
src/pybind/mgr/dashboard/tests/test_access_control.py
src/pybind/mgr/dashboard/tests/test_sso.py [new file with mode: 0644]
src/pybind/mgr/dashboard/tools.py
src/pybind/mgr/dashboard/tox.ini

index ac21ab4220000d00ee6b8b6691f50fc82e97b07a..5d938169229d486ee5dfdec06d49710021f4ae53 100644 (file)
@@ -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
 ^^^^^^^^^^^^^^^^^^^^^^^
 
index f0e5ead71b4c39d0e1a1aa3a84873568ca8013df..7baefd30e4110bb2d58972fcb93659434e7f305f 100755 (executable)
@@ -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
index 08e8783b40ce99e80c0e7df41e7e99c8e2e5980b..4e85b75a821d99f3145e9e2af7e8a97fdc30e2fc 100644 (file)
@@ -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
 
index 0b6ab0ab521c4df9577daa67df84d71f7fd7fdd4..0de3f278120757eefc4a2ccf04de87ce0691e346 100644 (file)
@@ -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")
index 4aaf84ea72aace4da614082a3b34fc058adbbc59..ce2b40a44521d21e5a856326cc3a456703cff198 100644 (file)
@@ -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):
     """
index 9c0effd48f7b5b897a67248647c80b1aa3464fb6..d1c6d7943ed8b0d61f410c01d41e6c5e94026842 100644 (file)
@@ -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 (file)
index 0000000..0cc5536
--- /dev/null
@@ -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))
index 79e7b5fa601c7335ba740bbe45ef90c44565d92b..5164c4b76ac863138523f07ce0497c802dff0284 100644 (file)
@@ -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' }
index a3d1d19af326f88bb0baba9eb6fa337b6f1830af..5734d43f594c7fe49029964b7dcb0dc6726cf077 100644 (file)
@@ -1,4 +1,5 @@
-<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"
index 0a90da28bdfaca72264832d1a9441384ab9bbfd9..108663be985fd358398525c8670b8398bb779de3 100644 (file)
@@ -14,6 +14,7 @@ import { AuthStorageService } from '../../../shared/services/auth-storage.servic
 })
 export class LoginComponent implements OnInit {
   model = new Credentials();
+  isLoginActive = false;
 
   constructor(
     private authService: AuthService,
@@ -33,6 +34,24 @@ export class LoginComponent implements OnInit {
       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(['']);
+        }
+      });
     }
   }
 
index 3b83f1a5f358fad18272ea511d1a22b73a06ce89..690a58e497931e67334477f64c945e4a22112819 100644 (file)
@@ -207,10 +207,8 @@ describe('UserFormComponent', () => {
       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', () => {
index a3febe965a90a199d99a6456d6ce0dde39902ef3..2b1530d84571ff322401cfb5efb18a0f3cd1a152 100644 (file)
@@ -194,7 +194,6 @@ export class UserFormComponent implements OnInit {
               NotificationType.info,
               'You were automatically logged out because your roles have been changed.'
             );
-            this.router.navigate(['/login']);
           });
         } else {
           this.notificationService.show(
index ffa18b1bd78b60dcdbbca4cb00546999498a55d8..ccc31cd22c90483ab45b8dc61c8eafea15dd0e63 100644 (file)
@@ -1,5 +1,4 @@
 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';
@@ -12,19 +11,13 @@ import { AuthStorageService } from '../../../shared/services/auth-storage.servic
 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();
   }
 }
index 6c0ecefeb8dd31b4ee1eaf1d1394dd659a0c433c..e7ff555705d6c57a3b440e836deb7e61cb15f1a6 100644 (file)
@@ -1,18 +1,21 @@
 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(() => {
@@ -43,9 +46,9 @@ describe('AuthService', () => {
 
   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);
   }));
index 68ed81f35b2312ce469b7ba98eb3b55ea2da6adc..fc940081f2e99c2ed830cc2b42f99d1ea97b5113 100644 (file)
@@ -1,5 +1,6 @@
 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';
@@ -10,7 +11,15 @@ import { ApiModule } from './api.module';
   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
@@ -21,12 +30,14 @@ export class AuthService {
       });
   }
 
-  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);
     });
   }
 }
index d6d34886a3fa78cdb660ad6d22470db0df35535c..1a80b581b7dfb333ce653b61526d5e3a88af2f2d 100644 (file)
@@ -55,6 +55,7 @@ export class ApiInterceptorService implements HttpInterceptor {
             case 401:
               this.authStorageService.remove();
               this.router.navigate(['/login']);
+              showNotification = false;
               break;
             case 403:
               this.router.navigate(['/403']);
index c08f2b4f05edc84c2e16e902681a58b2237d5b95..9558be86273a7f5dc8e3189e1eceee1f5606fcc2 100644 (file)
@@ -17,11 +17,6 @@ from OpenSSL import crypto
 
 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
@@ -29,6 +24,7 @@ except ImportError:
     # 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
@@ -59,10 +55,13 @@ 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 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
@@ -78,14 +77,6 @@ def os_exit_noop(*args):
 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
 
@@ -246,6 +237,7 @@ class Module(MgrModule, CherryPyConfig):
     ]
     COMMANDS.extend(options_command_list())
     COMMANDS.extend(ACCESS_CONTROL_COMMANDS)
+    COMMANDS.extend(SSO_COMMANDS)
 
     OPTIONS = [
         {'name': 'server_addr'},
@@ -289,6 +281,7 @@ class Module(MgrModule, CherryPyConfig):
             _cov.start()
 
         AuthManager.initialize()
+        load_sso_db()
 
         uri = self.await_configuration()
         if uri is None:
@@ -335,10 +328,14 @@ class Module(MgrModule, CherryPyConfig):
         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':
diff --git a/src/pybind/mgr/dashboard/requirements-py27.txt b/src/pybind/mgr/dashboard/requirements-py27.txt
new file mode 100644 (file)
index 0000000..d4062b8
--- /dev/null
@@ -0,0 +1 @@
+python-saml==2.4.2
diff --git a/src/pybind/mgr/dashboard/requirements-py3.txt b/src/pybind/mgr/dashboard/requirements-py3.txt
new file mode 100644 (file)
index 0000000..63da8ed
--- /dev/null
@@ -0,0 +1 @@
+python3-saml==1.4.1
diff --git a/src/pybind/mgr/dashboard/services/sso.py b/src/pybind/mgr/dashboard/services/sso.py
new file mode 100644 (file)
index 0000000..f29c3fa
--- /dev/null
@@ -0,0 +1,269 @@
+# -*- 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, '', ''
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..01e06ec9a1d5c1ed1bf89b0310112744d755469e 100644 (file)
@@ -0,0 +1,22 @@
+# -*- 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
index 3592c741f0db45a84a0c5837fbdc20616482a93c..74414aa23e365571d3a63a13d2f377eb0bc596ee 100644 (file)
@@ -7,6 +7,7 @@ import json
 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, \
@@ -15,12 +16,6 @@ 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 = {}
 
@@ -45,15 +40,7 @@ class AccessControlTest(unittest.TestCase):
 
     @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()
diff --git a/src/pybind/mgr/dashboard/tests/test_sso.py b/src/pybind/mgr/dashboard/tests/test_sso.py
new file mode 100644 (file)
index 0000000..35cb5df
--- /dev/null
@@ -0,0 +1,175 @@
+# -*- 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': {}
+        })
index ee8de3b96584e601f133026fafebc628e423436a..a2ce6a227c4ed8a186c39010438cc7e0be88c5fb 100644 (file)
@@ -16,6 +16,11 @@ import socket
 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
@@ -675,6 +680,14 @@ def build_url(host, scheme=None, port=None):
     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`.
index aa513ead13a20fe05d26ddafb5cc9dff472ac0f3..9e13769350393d1fb0313254b2f71453e241e852 100644 (file)
@@ -7,6 +7,8 @@ minversion = 2.8.1
 [testenv]
 deps =
     -r{toxinidir}/requirements.txt
+    py27: -r{toxinidir}/requirements-py27.txt
+    py3: -r{toxinidir}/requirements-py3.txt
 setenv=
     UNITTEST = true
     WEBTEST_INTERACTIVE = false