summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorRicardo Marques <rimarques@suse.com>2018-06-11 11:29:08 +0200
committerRicardo Marques <rimarques@suse.com>2018-11-08 16:27:37 +0100
commit04f4d5053e2181ba70731ce2d253af208dadc7f1 (patch)
tree8b7f1f0aa3d1633450a401e752b46f3267d8abae /src
parentMerge pull request #24817 from tone-zhang/wip-64-assert (diff)
downloadceph-04f4d5053e2181ba70731ce2d253af208dadc7f1.tar.xz
ceph-04f4d5053e2181ba70731ce2d253af208dadc7f1.zip
mgr/dashboard: SAML 2.0 support
Fixes: https://tracker.ceph.com/issues/24268 Signed-off-by: Ricardo Dias <rdias@suse.com> Signed-off-by: Ricardo Marques <rimarques@suse.com>
Diffstat (limited to 'src')
-rw-r--r--src/pybind/mgr/dashboard/controllers/__init__.py42
-rw-r--r--src/pybind/mgr/dashboard/controllers/auth.py47
-rw-r--r--src/pybind/mgr/dashboard/controllers/saml2.py115
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts1
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts19
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts6
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts1
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts1
-rw-r--r--src/pybind/mgr/dashboard/module.py25
-rw-r--r--src/pybind/mgr/dashboard/requirements-py27.txt1
-rw-r--r--src/pybind/mgr/dashboard/requirements-py3.txt1
-rw-r--r--src/pybind/mgr/dashboard/services/sso.py269
-rw-r--r--src/pybind/mgr/dashboard/tests/__init__.py22
-rw-r--r--src/pybind/mgr/dashboard/tests/test_access_control.py17
-rw-r--r--src/pybind/mgr/dashboard/tests/test_sso.py175
-rw-r--r--src/pybind/mgr/dashboard/tools.py13
-rw-r--r--src/pybind/mgr/dashboard/tox.ini2
21 files changed, 745 insertions, 58 deletions
diff --git a/src/pybind/mgr/dashboard/controllers/__init__.py b/src/pybind/mgr/dashboard/controllers/__init__.py
index 4aaf84ea72a..ce2b40a4452 100644
--- a/src/pybind/mgr/dashboard/controllers/__init__.py
+++ b/src/pybind/mgr/dashboard/controllers/__init__.py
@@ -83,7 +83,7 @@ class UiApiController(Controller):
def Endpoint(method=None, path=None, path_params=None, query_params=None,
- json_response=True, proxy=False):
+ json_response=True, proxy=False, xml=False):
if method is None:
method = 'GET'
@@ -133,7 +133,8 @@ def Endpoint(method=None, path=None, path_params=None, query_params=None,
'path_params': path_params,
'query_params': query_params,
'json_response': json_response,
- 'proxy': proxy
+ 'proxy': proxy,
+ 'xml': xml
}
return func
return _wrapper
@@ -374,7 +375,8 @@ class BaseController(object):
@property
def function(self):
return self.ctrl._request_wrapper(self.func, self.method,
- self.config['json_response'])
+ self.config['json_response'],
+ self.config['xml'])
@property
def method(self):
@@ -517,7 +519,7 @@ class BaseController(object):
return result
@staticmethod
- def _request_wrapper(func, method, json_response): # pylint: disable=unused-argument
+ def _request_wrapper(func, method, json_response, xml): # pylint: disable=unused-argument
@wraps(func)
def inner(*args, **kwargs):
for key, value in kwargs.items():
@@ -533,12 +535,44 @@ class BaseController(object):
ret = func(*args, **kwargs)
if isinstance(ret, bytes):
ret = ret.decode('utf-8')
+ if xml:
+ cherrypy.response.headers['Content-Type'] = 'application/xml'
+ return ret.encode('utf8')
if json_response:
cherrypy.response.headers['Content-Type'] = 'application/json'
ret = json.dumps(ret).encode('utf8')
return ret
return inner
+ @property
+ def _request(self):
+ return self.Request(cherrypy.request)
+
+ class Request(object):
+ def __init__(self, cherrypy_req):
+ self._creq = cherrypy_req
+
+ @property
+ def scheme(self):
+ return self._creq.scheme
+
+ @property
+ def host(self):
+ base = self._creq.base
+ base = base[len(self.scheme)+3:]
+ return base[:base.find(":")] if ":" in base else base
+
+ @property
+ def port(self):
+ base = self._creq.base
+ base = base[len(self.scheme)+3:]
+ default_port = 443 if self.scheme == 'https' else 80
+ return int(base[base.find(":")+1:]) if ":" in base else default_port
+
+ @property
+ def path_info(self):
+ return self._creq.path_info
+
class RESTController(BaseController):
"""
diff --git a/src/pybind/mgr/dashboard/controllers/auth.py b/src/pybind/mgr/dashboard/controllers/auth.py
index 9c0effd48f7..d1c6d7943ed 100644
--- a/src/pybind/mgr/dashboard/controllers/auth.py
+++ b/src/pybind/mgr/dashboard/controllers/auth.py
@@ -2,11 +2,14 @@
from __future__ import absolute_import
import cherrypy
+import jwt
from . import ApiController, RESTController
from .. import logger
from ..exceptions import DashboardException
from ..services.auth import AuthManager, JwtManager
+from ..services.access_control import UserDoesNotExist
+from ..services.sso import SSO_DB
@ApiController('/auth', secure=False)
@@ -34,6 +37,48 @@ class Auth(RESTController):
code='invalid_credentials',
component='auth')
- def bulk_delete(self):
+ @RESTController.Collection('POST')
+ def logout(self):
+ logger.debug('Logout successful')
token = JwtManager.get_token_from_header()
JwtManager.blacklist_token(token)
+ redirect_url = '#/login'
+ if SSO_DB.protocol == 'saml2':
+ redirect_url = 'auth/saml2/slo'
+ return {
+ 'redirect_url': redirect_url
+ }
+
+ def _get_login_url(self):
+ if SSO_DB.protocol == 'saml2':
+ return 'auth/saml2/login'
+ return '#/login'
+
+ @RESTController.Collection('POST')
+ def check(self, token):
+ if token:
+ try:
+ token = JwtManager.decode_token(token)
+ if not JwtManager.is_blacklisted(token['jti']):
+ user = AuthManager.get_user(token['username'])
+ if user.lastUpdate <= token['iat']:
+ return {
+ 'username': user.username,
+ 'permissions': user.permissions_dict(),
+ }
+
+ logger.debug("AMT: user info changed after token was"
+ " issued, iat=%s lastUpdate=%s",
+ token['iat'], user.lastUpdate)
+ else:
+ logger.debug('AMT: Token is black-listed')
+ except jwt.exceptions.ExpiredSignatureError:
+ logger.debug("AMT: Token has expired")
+ except jwt.exceptions.InvalidTokenError:
+ logger.debug("AMT: Failed to decode token")
+ except UserDoesNotExist:
+ logger.debug("AMT: Invalid token: user %s does not exist",
+ token['username'])
+ return {
+ 'login_url': self._get_login_url()
+ }
diff --git a/src/pybind/mgr/dashboard/controllers/saml2.py b/src/pybind/mgr/dashboard/controllers/saml2.py
new file mode 100644
index 00000000000..0cc55367539
--- /dev/null
+++ b/src/pybind/mgr/dashboard/controllers/saml2.py
@@ -0,0 +1,115 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import sys
+import cherrypy
+
+try:
+ from onelogin.saml2.auth import OneLogin_Saml2_Auth
+ from onelogin.saml2.errors import OneLogin_Saml2_Error
+ from onelogin.saml2.settings import OneLogin_Saml2_Settings
+
+ python_saml_imported = True
+except ImportError:
+ python_saml_imported = False
+
+from .. import mgr, logger
+from ..exceptions import UserDoesNotExist
+from ..services.auth import JwtManager
+from ..services.access_control import ACCESS_CTRL_DB
+from ..services.sso import SSO_DB
+from ..tools import prepare_url_prefix
+from . import Controller, Endpoint, BaseController
+
+
+@Controller('/auth/saml2', secure=False)
+class Saml2(BaseController):
+
+ @staticmethod
+ def _build_req(request, post_data):
+ return {
+ 'https': 'on' if request.scheme == 'https' else 'off',
+ 'http_host': request.host,
+ 'script_name': request.path_info,
+ 'server_port': str(request.port),
+ 'get_data': {},
+ 'post_data': post_data
+ }
+
+ @staticmethod
+ def _check_python_saml():
+ if not python_saml_imported:
+ python_saml_name = 'python3-saml' if sys.version_info >= (3, 0) else 'python-saml'
+ raise cherrypy.HTTPError(400,
+ 'Required library not found: `{}`'.format(python_saml_name))
+ try:
+ OneLogin_Saml2_Settings(SSO_DB.saml2.onelogin_settings)
+ except OneLogin_Saml2_Error:
+ raise cherrypy.HTTPError(400, 'Single Sign-On is not configured.')
+
+ @Endpoint('POST', path="")
+ def auth_response(self, **kwargs):
+ Saml2._check_python_saml()
+ req = Saml2._build_req(self._request, kwargs)
+ auth = OneLogin_Saml2_Auth(req, SSO_DB.saml2.onelogin_settings)
+ auth.process_response()
+ errors = auth.get_errors()
+
+ if auth.is_authenticated():
+ JwtManager.reset_user()
+ username_attribute = auth.get_attribute(SSO_DB.saml2.get_username_attribute())
+ if username_attribute is None:
+ raise cherrypy.HTTPError(400,
+ 'SSO error - `{}` not found in auth attributes. '
+ 'Received attributes: {}'
+ .format(
+ SSO_DB.saml2.get_username_attribute(),
+ auth.get_attributes()))
+ username = username_attribute[0]
+ try:
+ ACCESS_CTRL_DB.get_user(username)
+ except UserDoesNotExist:
+ raise cherrypy.HTTPError(400,
+ 'SSO error - Username `{}` does not exist.'
+ .format(username))
+
+ token = JwtManager.gen_token(username)
+ JwtManager.set_user(JwtManager.decode_token(token))
+ token = token.decode('utf-8')
+ logger.debug("JWT Token: %s", token)
+ url_prefix = prepare_url_prefix(mgr.get_config('url_prefix', default=''))
+ raise cherrypy.HTTPRedirect("{}/#/login?access_token={}".format(url_prefix, token))
+ else:
+ return {
+ 'is_authenticated': auth.is_authenticated(),
+ 'errors': errors,
+ 'reason': auth.get_last_error_reason()
+ }
+
+ @Endpoint(xml=True)
+ def metadata(self):
+ Saml2._check_python_saml()
+ saml_settings = OneLogin_Saml2_Settings(SSO_DB.saml2.onelogin_settings)
+ return saml_settings.get_sp_metadata()
+
+ @Endpoint(json_response=False)
+ def login(self):
+ Saml2._check_python_saml()
+ req = Saml2._build_req(self._request, {})
+ auth = OneLogin_Saml2_Auth(req, SSO_DB.saml2.onelogin_settings)
+ raise cherrypy.HTTPRedirect(auth.login())
+
+ @Endpoint(json_response=False)
+ def slo(self):
+ Saml2._check_python_saml()
+ req = Saml2._build_req(self._request, {})
+ auth = OneLogin_Saml2_Auth(req, SSO_DB.saml2.onelogin_settings)
+ raise cherrypy.HTTPRedirect(auth.logout())
+
+ @Endpoint(json_response=False)
+ def logout(self, **kwargs):
+ # pylint: disable=unused-argument
+ Saml2._check_python_saml()
+ JwtManager.reset_user()
+ url_prefix = prepare_url_prefix(mgr.get_config('url_prefix', default=''))
+ raise cherrypy.HTTPRedirect("{}/#/login".format(url_prefix))
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
index 79e7b5fa601..5164c4b76ac 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
@@ -244,6 +244,7 @@ const routes: Routes = [
},
// System
{ path: 'login', component: LoginComponent },
+ { path: 'logout', children: [] },
{ path: '403', component: ForbiddenComponent },
{ path: '404', component: NotFoundComponent },
{ path: '**', redirectTo: '/404' }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html
index a3d1d19af32..5734d43f594 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html
@@ -1,4 +1,5 @@
-<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"
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts
index 0a90da28bdf..108663be985 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts
@@ -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(['']);
+ }
+ });
}
}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts
index 3b83f1a5f35..690a58e4979 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts
@@ -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', () => {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts
index a3febe965a9..2b1530d8457 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts
@@ -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(
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts
index ffa18b1bd78..ccc31cd22c9 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts
@@ -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();
}
}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts
index 6c0ecefeb8d..e7ff555705d 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts
@@ -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);
}));
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts
index 68ed81f35b2..fc940081f2e 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts
@@ -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);
});
}
}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts
index d6d34886a3f..1a80b581b7d 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts
@@ -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']);
diff --git a/src/pybind/mgr/dashboard/module.py b/src/pybind/mgr/dashboard/module.py
index c08f2b4f05e..9558be86273 100644
--- a/src/pybind/mgr/dashboard/module.py
+++ b/src/pybind/mgr/dashboard/module.py
@@ -18,17 +18,13 @@ 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
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,12 +328,16 @@ 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':
self.set_config('jwt_token_ttl', str(cmd['seconds']))
return 0, 'JWT token TTL updated', ''
diff --git a/src/pybind/mgr/dashboard/requirements-py27.txt b/src/pybind/mgr/dashboard/requirements-py27.txt
new file mode 100644
index 00000000000..d4062b8d228
--- /dev/null
+++ b/src/pybind/mgr/dashboard/requirements-py27.txt
@@ -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
index 00000000000..63da8edcc5c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/requirements-py3.txt
@@ -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
index 00000000000..f29c3fa512b
--- /dev/null
+++ b/src/pybind/mgr/dashboard/services/sso.py
@@ -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, '', ''
diff --git a/src/pybind/mgr/dashboard/tests/__init__.py b/src/pybind/mgr/dashboard/tests/__init__.py
index e69de29bb2d..01e06ec9a1d 100644
--- a/src/pybind/mgr/dashboard/tests/__init__.py
+++ b/src/pybind/mgr/dashboard/tests/__init__.py
@@ -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
diff --git a/src/pybind/mgr/dashboard/tests/test_access_control.py b/src/pybind/mgr/dashboard/tests/test_access_control.py
index 3592c741f0d..74414aa23e3 100644
--- a/src/pybind/mgr/dashboard/tests/test_access_control.py
+++ b/src/pybind/mgr/dashboard/tests/test_access_control.py
@@ -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
index 00000000000..35cb5df855a
--- /dev/null
+++ b/src/pybind/mgr/dashboard/tests/test_sso.py
@@ -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': {}
+ })
diff --git a/src/pybind/mgr/dashboard/tools.py b/src/pybind/mgr/dashboard/tools.py
index ee8de3b9658..a2ce6a227c4 100644
--- a/src/pybind/mgr/dashboard/tools.py
+++ b/src/pybind/mgr/dashboard/tools.py
@@ -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`.
diff --git a/src/pybind/mgr/dashboard/tox.ini b/src/pybind/mgr/dashboard/tox.ini
index aa513ead13a..9e137693503 100644
--- a/src/pybind/mgr/dashboard/tox.ini
+++ b/src/pybind/mgr/dashboard/tox.ini
@@ -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