diff options
author | Ricardo Marques <rimarques@suse.com> | 2018-06-11 11:29:08 +0200 |
---|---|---|
committer | Ricardo Marques <rimarques@suse.com> | 2018-11-08 16:27:37 +0100 |
commit | 04f4d5053e2181ba70731ce2d253af208dadc7f1 (patch) | |
tree | 8b7f1f0aa3d1633450a401e752b46f3267d8abae /src | |
parent | Merge pull request #24817 from tone-zhang/wip-64-assert (diff) | |
download | ceph-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')
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 |