From 826b327520c03b418017f75c66c503a2b62da32a Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 6 Jan 2020 15:58:16 +0100 Subject: [PATCH] Changes --- x-pack/legacy/plugins/security/index.d.ts | 15 - x-pack/legacy/plugins/security/index.ts | 143 +++------- .../plugins/security/public/hacks/legacy.ts | 65 +++++ .../public/hacks/on_session_timeout.js | 31 --- .../public/hacks/on_unauthorized_response.js | 38 --- .../legacy/plugins/security/public/index.scss | 15 - .../security/public/services/auto_logout.js | 33 --- .../plugins/security/public/views/account.tsx | 35 --- .../security/public/views/logged_out.tsx | 41 --- .../public/views/logged_out/logged_out.html | 1 - .../plugins/security/public/views/login.tsx | 69 ----- .../public/views/login/components/_index.scss | 1 - .../plugins/security/public/views/logout.ts | 14 - .../public/views/overwritten_session.tsx | 48 ---- .../server/lib/__tests__/parse_next.js | 172 ------------ .../plugins/security/server/lib/parse_next.js | 37 --- .../security/common/licensing/index.ts | 2 +- .../common/licensing/license_features.ts | 7 +- .../account_management_app.test.ts | 68 +++++ .../account_management_app.ts | 51 ++++ .../account_management_page.tsx | 22 +- .../public/account_management/index.ts | 2 +- .../public/authentication/_index.scss | 3 + .../authentication/authentication_service.ts | 34 ++- .../authentication/components/index.ts} | 2 +- .../authentication/logged_out/index.ts} | 2 +- .../logged_out/logged_out_app.test.ts | 53 ++++ .../logged_out/logged_out_app.ts | 34 +++ .../logged_out/logged_out_page.tsx | 44 +++ .../__snapshots__/login_page.test.tsx.snap | 259 +++++++++++++++-- .../basic_login_form.test.tsx | 142 +++++----- .../basic_login_form/basic_login_form.tsx | 75 +++-- .../authentication/login/components/index.ts | 3 +- .../public/authentication/login/index.ts | 2 +- .../authentication/login/login_app.test.ts | 65 +++++ .../public/authentication/login/login_app.ts | 43 +++ .../authentication/login/login_page.test.tsx | 260 +++++++++++------- .../authentication/login/login_page.tsx | 132 ++++++--- .../authentication/login/login_state.ts | 2 +- .../public/authentication/logout}/index.ts | 2 +- .../authentication/logout/logout_app.test.ts | 61 ++++ .../authentication/logout/logout_app.ts | 36 +++ .../overwritten_session/index.ts | 2 +- .../overwritten_session_app.test.ts | 62 +++++ .../overwritten_session_app.ts | 39 +++ .../overwritten_session_page.tsx | 63 +++++ .../security/public/config.ts} | 4 +- x-pack/plugins/security/public/index.scss | 3 + x-pack/plugins/security/public/index.ts | 7 +- .../nav_control/nav_control_service.test.ts | 5 + .../nav_control/nav_control_service.tsx | 11 +- .../plugins/security/public/plugin.test.tsx | 134 +++++++++ x-pack/plugins/security/public/plugin.tsx | 67 +++-- x-pack/plugins/security/server/config.test.ts | 106 +++---- x-pack/plugins/security/server/config.ts | 58 ++-- x-pack/plugins/security/server/index.ts | 3 + x-pack/plugins/security/server/plugin.test.ts | 2 - x-pack/plugins/security/server/plugin.ts | 16 +- .../routes/authentication/basic.test.ts | 27 +- .../routes/authentication/common.test.ts | 27 +- .../routes/authentication/index.test.ts | 23 +- .../server/routes/authentication/saml.test.ts | 29 +- .../security/server/routes/index.mock.ts | 2 + .../plugins/security/server/routes/index.ts | 4 + .../routes/users/change_password.test.ts | 29 +- .../server/routes/views/account_management.ts | 19 ++ .../security/server/routes/views/index.ts | 26 ++ .../server/routes/views/logged_out.ts | 61 ++-- .../security/server/routes/views/login.ts | 90 +++--- .../security/server/routes/views/logout.ts | 28 +- .../routes/views/overwritten_session.ts | 24 +- 71 files changed, 1879 insertions(+), 1256 deletions(-) delete mode 100644 x-pack/legacy/plugins/security/index.d.ts create mode 100644 x-pack/legacy/plugins/security/public/hacks/legacy.ts delete mode 100644 x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js delete mode 100644 x-pack/legacy/plugins/security/public/hacks/on_unauthorized_response.js delete mode 100644 x-pack/legacy/plugins/security/public/index.scss delete mode 100644 x-pack/legacy/plugins/security/public/services/auto_logout.js delete mode 100644 x-pack/legacy/plugins/security/public/views/account.tsx delete mode 100644 x-pack/legacy/plugins/security/public/views/logged_out.tsx delete mode 100644 x-pack/legacy/plugins/security/public/views/logged_out/logged_out.html delete mode 100644 x-pack/legacy/plugins/security/public/views/login.tsx delete mode 100644 x-pack/legacy/plugins/security/public/views/login/components/_index.scss delete mode 100644 x-pack/legacy/plugins/security/public/views/logout.ts delete mode 100644 x-pack/legacy/plugins/security/public/views/overwritten_session.tsx delete mode 100644 x-pack/legacy/plugins/security/server/lib/__tests__/parse_next.js delete mode 100644 x-pack/legacy/plugins/security/server/lib/parse_next.js create mode 100644 x-pack/plugins/security/public/account_management/account_management_app.test.ts create mode 100644 x-pack/plugins/security/public/account_management/account_management_app.ts rename x-pack/{legacy/plugins/security/public/hacks/register_account_management_app.ts => plugins/security/public/authentication/components/index.ts} (77%) rename x-pack/{legacy/plugins/security/public/views/logout/index.js => plugins/security/public/authentication/logged_out/index.ts} (83%) create mode 100644 x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts create mode 100644 x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts create mode 100644 x-pack/plugins/security/public/authentication/logged_out/logged_out_page.tsx create mode 100644 x-pack/plugins/security/public/authentication/login/login_app.test.ts create mode 100644 x-pack/plugins/security/public/authentication/login/login_app.ts rename x-pack/{legacy/plugins/security/public/views/login => plugins/security/public/authentication/logout}/index.ts (85%) create mode 100644 x-pack/plugins/security/public/authentication/logout/logout_app.test.ts create mode 100644 x-pack/plugins/security/public/authentication/logout/logout_app.ts create mode 100644 x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts create mode 100644 x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts create mode 100644 x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx rename x-pack/{legacy/plugins/security/public/views/logged_out/index.js => plugins/security/public/config.ts} (78%) create mode 100644 x-pack/plugins/security/public/plugin.test.tsx create mode 100644 x-pack/plugins/security/server/routes/views/account_management.ts create mode 100644 x-pack/plugins/security/server/routes/views/index.ts diff --git a/x-pack/legacy/plugins/security/index.d.ts b/x-pack/legacy/plugins/security/index.d.ts deleted file mode 100644 index d453415f73376..0000000000000 --- a/x-pack/legacy/plugins/security/index.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Legacy } from 'kibana'; -import { AuthenticatedUser } from '../../../plugins/security/public'; - -/** - * Public interface of the security plugin. - */ -export interface SecurityPlugin { - getUser: (request: Legacy.Request) => Promise; -} diff --git a/x-pack/legacy/plugins/security/index.ts b/x-pack/legacy/plugins/security/index.ts index 18b815fb429cb..deebbccf5aa49 100644 --- a/x-pack/legacy/plugins/security/index.ts +++ b/x-pack/legacy/plugins/security/index.ts @@ -4,117 +4,75 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Root } from 'joi'; import { resolve } from 'path'; -import { initOverwrittenSessionView } from './server/routes/views/overwritten_session'; -import { initLoginView } from './server/routes/views/login'; -import { initLogoutView } from './server/routes/views/logout'; -import { initLoggedOutView } from './server/routes/views/logged_out'; +import { Server } from 'src/legacy/server/kbn_server'; +import { KibanaRequest, LegacyRequest } from '../../../../src/core/server'; +// @ts-ignore import { AuditLogger } from '../../server/lib/audit_logger'; +// @ts-ignore import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; -import { KibanaRequest } from '../../../../src/core/server'; +import { AuthenticatedUser, SecurityPluginSetup } from '../../../plugins/security/server'; -export const security = kibana => +/** + * Public interface of the security plugin. + */ +export interface SecurityPlugin { + getUser: (request: LegacyRequest) => Promise; +} + +function getSecurityPluginSetup(server: Server) { + const securityPlugin = server.newPlatform.setup.plugins.security as SecurityPluginSetup; + if (!securityPlugin) { + throw new Error('Kibana Platform Security plugin is not available.'); + } + + return securityPlugin; +} + +export const security = (kibana: Record) => new kibana.Plugin({ id: 'security', configPrefix: 'xpack.security', publicDir: resolve(__dirname, 'public'), require: ['kibana', 'elasticsearch', 'xpack_main'], - config(Joi) { - const HANDLED_IN_NEW_PLATFORM = Joi.any().description( - 'This key is handled in the new platform security plugin ONLY' - ); + // This config is only used by `AuditLogger` and should be removed as soon as `AuditLogger` + // is migrated to Kibana Platform. + config(Joi: Root) { return Joi.object({ enabled: Joi.boolean().default(true), - cookieName: HANDLED_IN_NEW_PLATFORM, - encryptionKey: HANDLED_IN_NEW_PLATFORM, - session: HANDLED_IN_NEW_PLATFORM, - secureCookies: HANDLED_IN_NEW_PLATFORM, - loginAssistanceMessage: HANDLED_IN_NEW_PLATFORM, - authorization: HANDLED_IN_NEW_PLATFORM, - audit: Joi.object({ - enabled: Joi.boolean().default(false), - }).default(), - authc: HANDLED_IN_NEW_PLATFORM, - }).default(); + audit: Joi.object({ enabled: Joi.boolean().default(false) }).default(), + }) + .unknown() + .default(); }, uiExports: { - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - apps: [ - { - id: 'login', - title: 'Login', - main: 'plugins/security/views/login', - hidden: true, - }, - { - id: 'overwritten_session', - title: 'Overwritten Session', - main: 'plugins/security/views/overwritten_session', - description: - 'The view is shown when user had an active session previously, but logged in as a different user.', - hidden: true, - }, - { - id: 'logout', - title: 'Logout', - main: 'plugins/security/views/logout', - hidden: true, - }, - { - id: 'logged_out', - title: 'Logged out', - main: 'plugins/security/views/logged_out', - hidden: true, - }, - ], - hacks: [ - 'plugins/security/hacks/on_session_timeout', - 'plugins/security/hacks/on_unauthorized_response', - 'plugins/security/hacks/register_account_management_app', - ], - injectDefaultVars: server => { - const securityPlugin = server.newPlatform.setup.plugins.security; - if (!securityPlugin) { - throw new Error('New Platform XPack Security plugin is not available.'); - } - + hacks: ['plugins/security/hacks/legacy'], + injectDefaultVars: (server: Server) => { return { - secureCookies: securityPlugin.__legacyCompat.config.secureCookies, - session: { - tenant: server.newPlatform.setup.core.http.basePath.serverBasePath, - }, + secureCookies: getSecurityPluginSetup(server).__legacyCompat.config.secureCookies, enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'), - logoutUrl: `${server.newPlatform.setup.core.http.basePath.serverBasePath}/logout`, }; }, }, - async postInit(server) { - const securityPlugin = server.newPlatform.setup.plugins.security; - if (!securityPlugin) { - throw new Error('New Platform XPack Security plugin is not available.'); - } - + async postInit(server: Server) { watchStatusAndLicenseToInitialize(server.plugins.xpack_main, this, async () => { const xpackInfo = server.plugins.xpack_main.info; if (xpackInfo.isAvailable() && xpackInfo.feature('security').isEnabled()) { - await securityPlugin.__legacyCompat.registerPrivilegesWithCluster(); + await getSecurityPluginSetup(server).__legacyCompat.registerPrivilegesWithCluster(); } }); }, - async init(server) { - const securityPlugin = server.newPlatform.setup.plugins.security; - if (!securityPlugin) { - throw new Error('New Platform XPack Security plugin is not available.'); - } + async init(server: Server) { + const securityPlugin = getSecurityPluginSetup(server); - const config = server.config(); const xpackInfo = server.plugins.xpack_main.info; securityPlugin.__legacyCompat.registerLegacyAPI({ - auditLogger: new AuditLogger(server, 'security', config, xpackInfo), + auditLogger: new AuditLogger(server, 'security', server.config(), xpackInfo), }); // Legacy xPack Info endpoint returns whatever we return in a callback for `registerLicenseCheckResultsGenerator` @@ -128,29 +86,8 @@ export const security = kibana => ); server.expose({ - getUser: async request => securityPlugin.authc.getCurrentUser(KibanaRequest.from(request)), - }); - - initLoginView(securityPlugin, server); - initLogoutView(server); - initLoggedOutView(securityPlugin, server); - initOverwrittenSessionView(server); - - server.injectUiAppVars('login', () => { - const { - showLogin, - allowLogin, - layout = 'form', - } = securityPlugin.__legacyCompat.license.getFeatures(); - const { loginAssistanceMessage } = securityPlugin.__legacyCompat.config; - return { - loginAssistanceMessage, - loginState: { - showLogin, - allowLogin, - layout, - }, - }; + getUser: async (request: LegacyRequest) => + securityPlugin.authc.getCurrentUser(KibanaRequest.from(request)), }); }, }); diff --git a/x-pack/legacy/plugins/security/public/hacks/legacy.ts b/x-pack/legacy/plugins/security/public/hacks/legacy.ts new file mode 100644 index 0000000000000..6ca810e7b2aa4 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/hacks/legacy.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// @ts-ignore +import { uiModules } from 'ui/modules'; +import { npSetup, npStart } from 'ui/new_platform'; +import routes from 'ui/routes'; +import { isSystemApiRequest } from '../../../../../../src/plugins/kibana_legacy/public'; +import { SecurityPluginSetup } from '../../../../../plugins/security/public'; + +const securityPluginSetup = (npSetup.plugins as any).security as SecurityPluginSetup; +if (securityPluginSetup) { + routes.when('/account', { + template: '
', + controller: () => npStart.core.application.navigateToApp('security'), + }); + + const getNextParameter = () => { + const { location } = window; + const next = encodeURIComponent(`${location.pathname}${location.search}${location.hash}`); + return `&next=${next}`; + }; + + const getProviderParameter = (tenant: string) => { + const key = `${tenant}/session_provider`; + const providerName = sessionStorage.getItem(key); + return providerName ? `&provider=${encodeURIComponent(providerName)}` : ''; + }; + + const module = uiModules.get('security', []); + module.config(($httpProvider: ng.IHttpProvider) => { + $httpProvider.interceptors.push(($q, $window, Promise) => { + const isAnonymous = npSetup.core.http.anonymousPaths.isAnonymous(window.location.pathname); + + function interceptorFactory(responseHandler: (response: ng.IHttpResponse) => any) { + return function interceptor(response: ng.IHttpResponse) { + // TODO: SHOULD WE CHECK THAT IT'S NOT ERROR RESPONSE (&& response.status !== 401)? + if (!isAnonymous && !isSystemApiRequest(response.config)) { + securityPluginSetup.sessionTimeout.extend(response.config.url); + } + + if (response.status !== 401 || isAnonymous) { + return responseHandler(response); + } + + const { logoutUrl, tenant } = securityPluginSetup.__legacyCompat; + const next = getNextParameter(); + const provider = getProviderParameter(tenant); + + $window.location.href = `${logoutUrl}?msg=SESSION_EXPIRED${next}${provider}`; + + return Promise.halt(); + }; + } + + return { + response: interceptorFactory(response => response), + responseError: interceptorFactory($q.reject), + }; + }); + }); +} diff --git a/x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js b/x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js deleted file mode 100644 index 3e3fd09bdbbdb..0000000000000 --- a/x-pack/legacy/plugins/security/public/hacks/on_session_timeout.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import { uiModules } from 'ui/modules'; -import { isSystemApiRequest } from 'ui/system_api'; -import { npSetup } from 'ui/new_platform'; - -const module = uiModules.get('security', []); -module.config($httpProvider => { - $httpProvider.interceptors.push($q => { - const isAnonymous = npSetup.core.http.anonymousPaths.isAnonymous(window.location.pathname); - - function interceptorFactory(responseHandler) { - return function interceptor(response) { - if (!isAnonymous && !isSystemApiRequest(response.config)) { - npSetup.plugins.security.sessionTimeout.extend(response.config.url); - } - return responseHandler(response); - }; - } - - return { - response: interceptorFactory(_.identity), - responseError: interceptorFactory($q.reject), - }; - }); -}); diff --git a/x-pack/legacy/plugins/security/public/hacks/on_unauthorized_response.js b/x-pack/legacy/plugins/security/public/hacks/on_unauthorized_response.js deleted file mode 100644 index 3e214db972b18..0000000000000 --- a/x-pack/legacy/plugins/security/public/hacks/on_unauthorized_response.js +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { identity } from 'lodash'; -import { uiModules } from 'ui/modules'; -import { Path } from 'plugins/xpack_main/services/path'; -import 'plugins/security/services/auto_logout'; - -function isUnauthorizedResponseAllowed(response) { - const API_WHITELIST = ['/internal/security/login', '/internal/security/users/.*/password']; - - const url = response.config.url; - return API_WHITELIST.some(api => url.match(api)); -} - -const module = uiModules.get('security'); -module.factory('onUnauthorizedResponse', ($q, autoLogout) => { - const isUnauthenticated = Path.isUnauthenticated(); - function interceptorFactory(responseHandler) { - return function interceptor(response) { - if (response.status === 401 && !isUnauthorizedResponseAllowed(response) && !isUnauthenticated) - return autoLogout(); - return responseHandler(response); - }; - } - - return { - response: interceptorFactory(identity), - responseError: interceptorFactory($q.reject), - }; -}); - -module.config($httpProvider => { - $httpProvider.interceptors.push('onUnauthorizedResponse'); -}); diff --git a/x-pack/legacy/plugins/security/public/index.scss b/x-pack/legacy/plugins/security/public/index.scss deleted file mode 100644 index 0050d01a52493..0000000000000 --- a/x-pack/legacy/plugins/security/public/index.scss +++ /dev/null @@ -1,15 +0,0 @@ -@import 'src/legacy/ui/public/styles/styling_constants'; - -// Prefix all styles with "kbn" to avoid conflicts. -// Examples -// secChart -// secChart__legend -// secChart__legend--small -// secChart__legend-isLoading - -// Public components -@import './components/index'; - -// Public views -@import './views/index'; - diff --git a/x-pack/legacy/plugins/security/public/services/auto_logout.js b/x-pack/legacy/plugins/security/public/services/auto_logout.js deleted file mode 100644 index fa4d149d1f2e6..0000000000000 --- a/x-pack/legacy/plugins/security/public/services/auto_logout.js +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { uiModules } from 'ui/modules'; -import chrome from 'ui/chrome'; - -const module = uiModules.get('security'); - -const getNextParameter = () => { - const { location } = window; - const next = encodeURIComponent(`${location.pathname}${location.search}${location.hash}`); - return `&next=${next}`; -}; - -const getProviderParameter = tenant => { - const key = `${tenant}/session_provider`; - const providerName = sessionStorage.getItem(key); - return providerName ? `&provider=${encodeURIComponent(providerName)}` : ''; -}; - -module.service('autoLogout', ($window, Promise) => { - return () => { - const logoutUrl = chrome.getInjected('logoutUrl'); - const tenant = `${chrome.getInjected('session.tenant', '')}`; - const next = getNextParameter(); - const provider = getProviderParameter(tenant); - $window.location.href = `${logoutUrl}?msg=SESSION_EXPIRED${next}${provider}`; - return Promise.halt(); - }; -}); diff --git a/x-pack/legacy/plugins/security/public/views/account.tsx b/x-pack/legacy/plugins/security/public/views/account.tsx deleted file mode 100644 index 13abc44e08f96..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/account.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import { i18n } from '@kbn/i18n'; -import { npStart } from 'ui/new_platform'; -import routes from 'ui/routes'; - -routes.when('/account', { - template: '
', - k7Breadcrumbs: () => [ - { - text: i18n.translate('xpack.security.account.breadcrumb', { - defaultMessage: 'Account Management', - }), - }, - ], - controllerAs: 'accountController', - controller($scope) { - $scope.$$postDigest(() => { - const domNode = document.getElementById('userProfileReactRoot'); - - render( - , - domNode - ); - - $scope.$on('$destroy', () => unmountComponentAtNode(domNode)); - }); - }, -}); diff --git a/x-pack/legacy/plugins/security/public/views/logged_out.tsx b/x-pack/legacy/plugins/security/public/views/logged_out.tsx deleted file mode 100644 index dbeb68875c1a9..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/logged_out.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButton } from '@elastic/eui'; -import { AuthenticationStatePage } from 'plugins/security/components/authentication_state_page'; -// @ts-ignore -import template from 'plugins/security/views/logged_out/logged_out.html'; -import React from 'react'; -import { render } from 'react-dom'; -import chrome from 'ui/chrome'; -import { I18nContext } from 'ui/i18n'; - -chrome - .setVisible(false) - .setRootTemplate(template) - .setRootController('logout', ($scope: any) => { - $scope.$$postDigest(() => { - const domNode = document.getElementById('reactLoggedOutRoot'); - render( - - - } - > - - - - - , - domNode - ); - }); - }); diff --git a/x-pack/legacy/plugins/security/public/views/logged_out/logged_out.html b/x-pack/legacy/plugins/security/public/views/logged_out/logged_out.html deleted file mode 100644 index b65df2b53f26c..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/logged_out/logged_out.html +++ /dev/null @@ -1 +0,0 @@ -
diff --git a/x-pack/legacy/plugins/security/public/views/login.tsx b/x-pack/legacy/plugins/security/public/views/login.tsx deleted file mode 100644 index 0b89ac553c9a8..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/login.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { get } from 'lodash'; -import { LoginPage } from 'plugins/security/views/login/components'; -import React from 'react'; -import { render } from 'react-dom'; -import chrome from 'ui/chrome'; -import { I18nContext } from 'ui/i18n'; -import { parse } from 'url'; -import { parseNext } from './parse_next'; -import { LoginState } from './login_state'; -const messageMap = { - SESSION_EXPIRED: i18n.translate('xpack.security.login.sessionExpiredDescription', { - defaultMessage: 'Your session has timed out. Please log in again.', - }), - LOGGED_OUT: i18n.translate('xpack.security.login.loggedOutDescription', { - defaultMessage: 'You have logged out of Kibana.', - }), -}; - -interface AnyObject { - [key: string]: any; -} - -(chrome as AnyObject) - .setVisible(false) - .setRootTemplate('
') - .setRootController( - 'login', - ( - $scope: AnyObject, - $http: AnyObject, - $window: AnyObject, - secureCookies: boolean, - loginState: LoginState, - loginAssistanceMessage: string - ) => { - const basePath = chrome.getBasePath(); - const next = parseNext($window.location.href, basePath); - const isSecure = !!$window.location.protocol.match(/^https/); - - $scope.$$postDigest(() => { - const domNode = document.getElementById('reactLoginRoot'); - - const msgQueryParam = parse($window.location.href, true).query.msg || ''; - - render( - - - , - domNode - ); - }); - } - ); diff --git a/x-pack/legacy/plugins/security/public/views/login/components/_index.scss b/x-pack/legacy/plugins/security/public/views/login/components/_index.scss deleted file mode 100644 index a6f9598b9cc04..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/login/components/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './login_page/index'; diff --git a/x-pack/legacy/plugins/security/public/views/logout.ts b/x-pack/legacy/plugins/security/public/views/logout.ts deleted file mode 100644 index 97010ec81bbf5..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/logout.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import chrome from 'ui/chrome'; - -chrome.setVisible(false).setRootController('logout', $window => { - $window.sessionStorage.clear(); - - // Redirect user to the server logout endpoint to complete logout. - $window.location.href = chrome.addBasePath(`/api/security/logout${$window.location.search}`); -}); diff --git a/x-pack/legacy/plugins/security/public/views/overwritten_session.tsx b/x-pack/legacy/plugins/security/public/views/overwritten_session.tsx deleted file mode 100644 index 4c79c499cc0e6..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/overwritten_session.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButton } from '@elastic/eui'; -import React from 'react'; -import { render } from 'react-dom'; -import chrome from 'ui/chrome'; -import { I18nContext } from 'ui/i18n'; -import { npSetup } from 'ui/new_platform'; -import { AuthenticatedUser, SecurityPluginSetup } from '../../../../../../plugins/security/public'; -import { AuthenticationStatePage } from '../../components/authentication_state_page'; - -chrome - .setVisible(false) - .setRootTemplate('
') - .setRootController('overwritten_session', ($scope: any) => { - $scope.$$postDigest(() => { - ((npSetup.plugins as unknown) as { security: SecurityPluginSetup }).security.authc - .getCurrentUser() - .then((user: AuthenticatedUser) => { - const overwrittenSessionPage = ( - - - } - > - - - - - - ); - render(overwrittenSessionPage, document.getElementById('reactOverwrittenSessionRoot')); - }); - }); - }); diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/parse_next.js b/x-pack/legacy/plugins/security/server/lib/__tests__/parse_next.js deleted file mode 100644 index 7516433c77f83..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/parse_next.js +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { parseNext } from '../parse_next'; - -describe('parseNext', () => { - it('should return a function', () => { - expect(parseNext).to.be.a('function'); - }); - - describe('with basePath defined', () => { - // trailing slash is important since it must match the cookie path exactly - it('should return basePath with a trailing slash when next is not specified', () => { - const basePath = '/iqf'; - const href = `${basePath}/login`; - expect(parseNext(href, basePath)).to.equal(`${basePath}/`); - }); - - it('should properly handle next without hash', () => { - const basePath = '/iqf'; - const next = `${basePath}/app/kibana`; - const href = `${basePath}/login?next=${next}`; - expect(parseNext(href, basePath)).to.equal(next); - }); - - it('should properly handle next with hash', () => { - const basePath = '/iqf'; - const next = `${basePath}/app/kibana`; - const hash = '/discover/New-Saved-Search'; - const href = `${basePath}/login?next=${next}#${hash}`; - expect(parseNext(href, basePath)).to.equal(`${next}#${hash}`); - }); - - it('should properly decode special characters', () => { - const basePath = '/iqf'; - const next = `${encodeURIComponent(basePath)}%2Fapp%2Fkibana`; - const hash = '/discover/New-Saved-Search'; - const href = `${basePath}/login?next=${next}#${hash}`; - expect(parseNext(href, basePath)).to.equal(decodeURIComponent(`${next}#${hash}`)); - }); - - // to help prevent open redirect to a different url - it('should return basePath if next includes a protocol/hostname', () => { - const basePath = '/iqf'; - const next = `https://example.com${basePath}/app/kibana`; - const href = `${basePath}/login?next=${next}`; - expect(parseNext(href, basePath)).to.equal(`${basePath}/`); - }); - - // to help prevent open redirect to a different url by abusing encodings - it('should return basePath if including a protocol/host even if it is encoded', () => { - const basePath = '/iqf'; - const baseUrl = `http://example.com${basePath}`; - const next = `${encodeURIComponent(baseUrl)}%2Fapp%2Fkibana`; - const hash = '/discover/New-Saved-Search'; - const href = `${basePath}/login?next=${next}#${hash}`; - expect(parseNext(href, basePath)).to.equal(`${basePath}/`); - }); - - // to help prevent open redirect to a different port - it('should return basePath if next includes a port', () => { - const basePath = '/iqf'; - const next = `http://localhost:5601${basePath}/app/kibana`; - const href = `${basePath}/login?next=${next}`; - expect(parseNext(href, basePath)).to.equal(`${basePath}/`); - }); - - // to help prevent open redirect to a different port by abusing encodings - it('should return basePath if including a port even if it is encoded', () => { - const basePath = '/iqf'; - const baseUrl = `http://example.com:5601${basePath}`; - const next = `${encodeURIComponent(baseUrl)}%2Fapp%2Fkibana`; - const hash = '/discover/New-Saved-Search'; - const href = `${basePath}/login?next=${next}#${hash}`; - expect(parseNext(href, basePath)).to.equal(`${basePath}/`); - }); - - // to help prevent open redirect to a different base path - it('should return basePath if next does not begin with basePath', () => { - const basePath = '/iqf'; - const next = '/notbasepath/app/kibana'; - const href = `${basePath}/login?next=${next}`; - expect(parseNext(href, basePath)).to.equal(`${basePath}/`); - }); - - // disallow network-path references - it('should return / if next is url without protocol', () => { - const nextWithTwoSlashes = '//example.com'; - const hrefWithTwoSlashes = `/login?next=${nextWithTwoSlashes}`; - expect(parseNext(hrefWithTwoSlashes)).to.equal('/'); - - const nextWithThreeSlashes = '///example.com'; - const hrefWithThreeSlashes = `/login?next=${nextWithThreeSlashes}`; - expect(parseNext(hrefWithThreeSlashes)).to.equal('/'); - }); - }); - - describe('without basePath defined', () => { - // trailing slash is important since it must match the cookie path exactly - it('should return / with a trailing slash when next is not specified', () => { - const href = '/login'; - expect(parseNext(href)).to.equal('/'); - }); - - it('should properly handle next without hash', () => { - const next = '/app/kibana'; - const href = `/login?next=${next}`; - expect(parseNext(href)).to.equal(next); - }); - - it('should properly handle next with hash', () => { - const next = '/app/kibana'; - const hash = '/discover/New-Saved-Search'; - const href = `/login?next=${next}#${hash}`; - expect(parseNext(href)).to.equal(`${next}#${hash}`); - }); - - it('should properly decode special characters', () => { - const next = '%2Fapp%2Fkibana'; - const hash = '/discover/New-Saved-Search'; - const href = `/login?next=${next}#${hash}`; - expect(parseNext(href)).to.equal(decodeURIComponent(`${next}#${hash}`)); - }); - - // to help prevent open redirect to a different url - it('should return / if next includes a protocol/hostname', () => { - const next = 'https://example.com/app/kibana'; - const href = `/login?next=${next}`; - expect(parseNext(href)).to.equal('/'); - }); - - // to help prevent open redirect to a different url by abusing encodings - it('should return / if including a protocol/host even if it is encoded', () => { - const baseUrl = 'http://example.com'; - const next = `${encodeURIComponent(baseUrl)}%2Fapp%2Fkibana`; - const hash = '/discover/New-Saved-Search'; - const href = `/login?next=${next}#${hash}`; - expect(parseNext(href)).to.equal('/'); - }); - - // to help prevent open redirect to a different port - it('should return / if next includes a port', () => { - const next = 'http://localhost:5601/app/kibana'; - const href = `/login?next=${next}`; - expect(parseNext(href)).to.equal('/'); - }); - - // to help prevent open redirect to a different port by abusing encodings - it('should return / if including a port even if it is encoded', () => { - const baseUrl = 'http://example.com:5601'; - const next = `${encodeURIComponent(baseUrl)}%2Fapp%2Fkibana`; - const hash = '/discover/New-Saved-Search'; - const href = `/login?next=${next}#${hash}`; - expect(parseNext(href)).to.equal('/'); - }); - - // disallow network-path references - it('should return / if next is url without protocol', () => { - const nextWithTwoSlashes = '//example.com'; - const hrefWithTwoSlashes = `/login?next=${nextWithTwoSlashes}`; - expect(parseNext(hrefWithTwoSlashes)).to.equal('/'); - - const nextWithThreeSlashes = '///example.com'; - const hrefWithThreeSlashes = `/login?next=${nextWithThreeSlashes}`; - expect(parseNext(hrefWithThreeSlashes)).to.equal('/'); - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/parse_next.js b/x-pack/legacy/plugins/security/server/lib/parse_next.js deleted file mode 100644 index c247043876c91..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/parse_next.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { parse } from 'url'; - -export function parseNext(href, basePath = '') { - const { query, hash } = parse(href, true); - if (!query.next) { - return `${basePath}/`; - } - - // validate that `next` is not attempting a redirect to somewhere - // outside of this Kibana install - const { protocol, hostname, port, pathname } = parse( - query.next, - false /* parseQueryString */, - true /* slashesDenoteHost */ - ); - - // We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not - // detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but - // browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser - // hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`) - // and the first slash that belongs to path. - if (protocol !== null || hostname !== null || port !== null) { - return `${basePath}/`; - } - - if (!String(pathname).startsWith(basePath)) { - return `${basePath}/`; - } - - return query.next + (hash || ''); -} diff --git a/x-pack/plugins/security/common/licensing/index.ts b/x-pack/plugins/security/common/licensing/index.ts index e8efae3dc6a6b..0cc9b9d204273 100644 --- a/x-pack/plugins/security/common/licensing/index.ts +++ b/x-pack/plugins/security/common/licensing/index.ts @@ -6,4 +6,4 @@ export { SecurityLicenseService, SecurityLicense } from './license_service'; -export { SecurityLicenseFeatures } from './license_features'; +export { LoginLayout, SecurityLicenseFeatures } from './license_features'; diff --git a/x-pack/plugins/security/common/licensing/license_features.ts b/x-pack/plugins/security/common/licensing/license_features.ts index 33f8370a1b43e..a57034a1c2caa 100644 --- a/x-pack/plugins/security/common/licensing/license_features.ts +++ b/x-pack/plugins/security/common/licensing/license_features.ts @@ -4,6 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +/** + * Represents types of login form layouts. + */ +export type LoginLayout = 'form' | 'error-es-unavailable' | 'error-xpack-unavailable'; + /** * Describes Security plugin features that depend on license. */ @@ -46,7 +51,7 @@ export interface SecurityLicenseFeatures { /** * Describes the layout of the login form if it's displayed. */ - readonly layout?: string; + readonly layout?: LoginLayout; /** * Message to show when security links are clicked throughout the kibana app. diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts new file mode 100644 index 0000000000000..feea37fb8f4d2 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('./account_management_page'); + +import { AppMount, AppNavLinkStatus } from 'src/core/public'; +import { UserAPIClient } from '../management'; +import { accountManagementApp } from './account_management_app'; + +import { coreMock } from '../../../../../src/core/public/mocks'; +import { securityMock } from '../mocks'; + +describe('accountManagementApp', () => { + it('properly registers application', () => { + const coreSetupMock = coreMock.createSetup(); + + accountManagementApp.create({ + application: coreSetupMock.application, + getStartServices: coreSetupMock.getStartServices, + authc: securityMock.createSetup().authc, + }); + + expect(coreSetupMock.application.register).toHaveBeenCalledTimes(1); + + const [[appRegistration]] = coreSetupMock.application.register.mock.calls; + expect(appRegistration).toEqual({ + id: 'security', + appRoute: '/security/account', + navLinkStatus: AppNavLinkStatus.hidden, + title: 'Account Management', + mount: expect.any(Function), + }); + }); + + it('properly sets breadcrumbs and renders application', async () => { + const coreSetupMock = coreMock.createSetup(); + const coreStartMock = coreMock.createStart(); + coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}]); + + const authcMock = securityMock.createSetup().authc; + const containerMock = document.createElement('div'); + + accountManagementApp.create({ + application: coreSetupMock.application, + getStartServices: coreSetupMock.getStartServices, + authc: authcMock, + }); + + const [[{ mount }]] = coreSetupMock.application.register.mock.calls; + await (mount as AppMount)({ element: containerMock, appBasePath: '', onAppLeave: jest.fn() }); + + expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1); + expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledWith([ + { text: 'Account Management' }, + ]); + + const mockRenderApp = jest.requireMock('./account_management_page').renderAccountManagementPage; + expect(mockRenderApp).toHaveBeenCalledTimes(1); + expect(mockRenderApp).toHaveBeenCalledWith(coreStartMock.i18n, containerMock, { + apiClient: expect.any(UserAPIClient), + authc: authcMock, + notifications: coreStartMock.notifications, + }); + }); +}); diff --git a/x-pack/plugins/security/public/account_management/account_management_app.ts b/x-pack/plugins/security/public/account_management/account_management_app.ts new file mode 100644 index 0000000000000..468903c593ed0 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, AppMountParameters } from 'src/core/public'; +import { AuthenticationServiceSetup } from '../authentication'; +import { UserAPIClient } from '../management'; + +interface CreateDeps { + application: CoreSetup['application']; + authc: AuthenticationServiceSetup; + getStartServices: CoreSetup['getStartServices']; +} + +export const accountManagementApp = Object.freeze({ + id: 'security', + create({ application, authc, getStartServices }: CreateDeps) { + application.register({ + id: this.id, + title: i18n.translate('xpack.security.account.breadcrumb', { + defaultMessage: 'Account Management', + }), + // TODO: switch to proper enum once https://github.com/elastic/kibana/issues/58327 is resolved. + navLinkStatus: 3, + appRoute: '/security/account', + async mount({ element }: AppMountParameters) { + const [[coreStart], { renderAccountManagementPage }] = await Promise.all([ + getStartServices(), + import('./account_management_page'), + ]); + + coreStart.chrome.setBreadcrumbs([ + { + text: i18n.translate('xpack.security.account.breadcrumb', { + defaultMessage: 'Account Management', + }), + }, + ]); + + return renderAccountManagementPage(coreStart.i18n, element, { + authc, + notifications: coreStart.notifications, + apiClient: new UserAPIClient(coreStart.http), + }); + }, + }); + }, +}); diff --git a/x-pack/plugins/security/public/account_management/account_management_page.tsx b/x-pack/plugins/security/public/account_management/account_management_page.tsx index 3f764adc7949f..4d2470ae688b2 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.tsx @@ -3,13 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiPage, EuiPageBody, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; import React, { useEffect, useState } from 'react'; -import { NotificationsStart } from 'src/core/public'; +import ReactDOM from 'react-dom'; +import { EuiPage, EuiPageBody, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import { CoreStart, NotificationsStart } from 'src/core/public'; import { getUserDisplayName, AuthenticatedUser } from '../../common/model'; import { AuthenticationServiceSetup } from '../authentication'; -import { ChangePassword } from './change_password'; import { UserAPIClient } from '../management'; +import { ChangePassword } from './change_password'; import { PersonalInfo } from './personal_info'; interface Props { @@ -46,3 +47,18 @@ export const AccountManagementPage = ({ apiClient, authc, notifications }: Props ); }; + +export function renderAccountManagementPage( + i18nStart: CoreStart['i18n'], + element: Element, + props: Props +) { + ReactDOM.render( + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +} diff --git a/x-pack/plugins/security/public/account_management/index.ts b/x-pack/plugins/security/public/account_management/index.ts index 0f119b7cc0b1d..4c805d152cd53 100644 --- a/x-pack/plugins/security/public/account_management/index.ts +++ b/x-pack/plugins/security/public/account_management/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { AccountManagementPage } from './account_management_page'; +export { accountManagementApp } from './account_management_app'; diff --git a/x-pack/plugins/security/public/authentication/_index.scss b/x-pack/plugins/security/public/authentication/_index.scss index 6c2a091adf536..0a423c00f0218 100644 --- a/x-pack/plugins/security/public/authentication/_index.scss +++ b/x-pack/plugins/security/public/authentication/_index.scss @@ -1,2 +1,5 @@ +// Component styles +@import './components/index'; + // Login styles @import './login/index'; diff --git a/x-pack/plugins/security/public/authentication/authentication_service.ts b/x-pack/plugins/security/public/authentication/authentication_service.ts index 2679bc20d6a7d..7b88b0f8573ba 100644 --- a/x-pack/plugins/security/public/authentication/authentication_service.ts +++ b/x-pack/plugins/security/public/authentication/authentication_service.ts @@ -4,11 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HttpSetup } from 'src/core/public'; +import { ApplicationSetup, CoreSetup, HttpSetup } from 'src/core/public'; import { AuthenticatedUser } from '../../common/model'; +import { ConfigType } from '../config'; +import { PluginStartDependencies } from '../plugin'; +import { loginApp } from './login'; +import { logoutApp } from './logout'; +import { loggedOutApp } from './logged_out'; +import { overwrittenSessionApp } from './overwritten_session'; interface SetupParams { + application: ApplicationSetup; + config: ConfigType; http: HttpSetup; + getStartServices: CoreSetup['getStartServices']; } export interface AuthenticationServiceSetup { @@ -19,13 +28,20 @@ export interface AuthenticationServiceSetup { } export class AuthenticationService { - public setup({ http }: SetupParams): AuthenticationServiceSetup { - return { - async getCurrentUser() { - return (await http.get('/internal/security/me', { - asSystemRequest: true, - })) as AuthenticatedUser; - }, - }; + public setup({ + application, + config, + getStartServices, + http, + }: SetupParams): AuthenticationServiceSetup { + const getCurrentUser = async () => + (await http.get('/internal/security/me', { asSystemRequest: true })) as AuthenticatedUser; + + loginApp.create({ application, config, getStartServices, http }); + logoutApp.create({ application, http }); + loggedOutApp.create({ application, getStartServices, http }); + overwrittenSessionApp.create({ application, authc: { getCurrentUser }, getStartServices }); + + return { getCurrentUser }; } } diff --git a/x-pack/legacy/plugins/security/public/hacks/register_account_management_app.ts b/x-pack/plugins/security/public/authentication/components/index.ts similarity index 77% rename from x-pack/legacy/plugins/security/public/hacks/register_account_management_app.ts rename to x-pack/plugins/security/public/authentication/components/index.ts index 4fdc2358246b9..b0f2324d6fe52 100644 --- a/x-pack/legacy/plugins/security/public/hacks/register_account_management_app.ts +++ b/x-pack/plugins/security/public/authentication/components/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../views/account/account'; +export { AuthenticationStatePage } from './authentication_state_page'; diff --git a/x-pack/legacy/plugins/security/public/views/logout/index.js b/x-pack/plugins/security/public/authentication/logged_out/index.ts similarity index 83% rename from x-pack/legacy/plugins/security/public/views/logout/index.js rename to x-pack/plugins/security/public/authentication/logged_out/index.ts index 56588d4f746f1..7f65c12c22a6c 100644 --- a/x-pack/legacy/plugins/security/public/views/logout/index.js +++ b/x-pack/plugins/security/public/authentication/logged_out/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import './logout'; +export { loggedOutApp } from './logged_out_app'; diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts new file mode 100644 index 0000000000000..0f3d92b85b0e1 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('./logged_out_page'); + +import { AppMount } from 'src/core/public'; +import { loggedOutApp } from './logged_out_app'; + +import { coreMock } from '../../../../../../src/core/public/mocks'; + +describe('loggedOutApp', () => { + it('properly registers application', () => { + const coreSetupMock = coreMock.createSetup(); + + loggedOutApp.create(coreSetupMock); + + expect(coreSetupMock.http.anonymousPaths.register).toHaveBeenCalledTimes(1); + expect(coreSetupMock.http.anonymousPaths.register).toHaveBeenCalledWith('/logged_out'); + + expect(coreSetupMock.application.register).toHaveBeenCalledTimes(1); + + const [[appRegistration]] = coreSetupMock.application.register.mock.calls; + expect(appRegistration).toEqual({ + id: 'logged_out', + chromeless: true, + appRoute: '/logged_out', + title: 'Logged out', + mount: expect.any(Function), + }); + }); + + it('properly renders application', async () => { + const coreSetupMock = coreMock.createSetup(); + const coreStartMock = coreMock.createStart(); + coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}]); + + const containerMock = document.createElement('div'); + + loggedOutApp.create(coreSetupMock); + + const [[{ mount }]] = coreSetupMock.application.register.mock.calls; + await (mount as AppMount)({ element: containerMock, appBasePath: '', onAppLeave: jest.fn() }); + + const mockRenderApp = jest.requireMock('./logged_out_page').renderLoggedOutPage; + expect(mockRenderApp).toHaveBeenCalledTimes(1); + expect(mockRenderApp).toHaveBeenCalledWith(coreStartMock.i18n, containerMock, { + basePath: coreStartMock.http.basePath, + }); + }); +}); diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts new file mode 100644 index 0000000000000..0293c0fe5df30 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, AppMountParameters, HttpSetup } from 'src/core/public'; + +interface CreateDeps { + application: CoreSetup['application']; + http: HttpSetup; + getStartServices: CoreSetup['getStartServices']; +} + +export const loggedOutApp = Object.freeze({ + id: 'logged_out', + create({ application, http, getStartServices }: CreateDeps) { + http.anonymousPaths.register('/logged_out'); + application.register({ + id: this.id, + title: i18n.translate('xpack.security.loggedOutAppTitle', { defaultMessage: 'Logged out' }), + chromeless: true, + appRoute: '/logged_out', + async mount({ element }: AppMountParameters) { + const [[coreStart], { renderLoggedOutPage }] = await Promise.all([ + getStartServices(), + import('./logged_out_page'), + ]); + return renderLoggedOutPage(coreStart.i18n, element, { basePath: coreStart.http.basePath }); + }, + }); + }, +}); diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_page.tsx b/x-pack/plugins/security/public/authentication/logged_out/logged_out_page.tsx new file mode 100644 index 0000000000000..a708931c3fa95 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_page.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CoreStart, IBasePath } from 'src/core/public'; +import { AuthenticationStatePage } from '../components'; + +interface Props { + basePath: IBasePath; +} + +export function LoggedOutPage({ basePath }: Props) { + return ( + + } + > + + + + + ); +} + +export function renderLoggedOutPage(i18nStart: CoreStart['i18n'], element: Element, props: Props) { + ReactDOM.render( + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +} diff --git a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap index 17ba81988414a..87bfba689ca8a 100644 --- a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap +++ b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap @@ -218,23 +218,69 @@ exports[`LoginPage disabled form states renders as expected when loginAssistance gutterSize="l" > - @@ -460,23 +506,198 @@ exports[`LoginPage enabled form state renders as expected 1`] = ` gutterSize="l" > - + + +
+
+`; + +exports[`LoginPage enabled form state renders as expected when info message is set 1`] = ` +
+
+
+ + + + + +

+ +

+
+ +

+ +

+
+ +
+
+
+ + + diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx index 3a970d582bdc8..e62fd7191dfae 100644 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx @@ -4,71 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiCallOut } from '@elastic/eui'; import React from 'react'; -import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { LoginState } from '../../login_state'; +import { act } from '@testing-library/react'; +import { EuiButton, EuiCallOut } from '@elastic/eui'; +import { mountWithIntl, nextTick, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { BasicLoginForm } from './basic_login_form'; -const createMockHttp = ({ simulateError = false } = {}) => { - return { - post: jest.fn(async () => { - if (simulateError) { - // eslint-disable-next-line no-throw-literal - throw { - data: { - statusCode: 401, - }, - }; - } - - return { - statusCode: 200, - }; - }), - }; -}; - -const createLoginState = (options?: Partial) => { - return { - allowLogin: true, - layout: 'form', - ...options, - } as LoginState; -}; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; describe('BasicLoginForm', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { href: 'https://some-host/bar' }, + writable: true, + }); + }); + + afterAll(() => { + delete (window as any).location; + }); + it('renders as expected', () => { - const mockHttp = createMockHttp(); - const mockWindow = {}; - const loginState = createLoginState(); expect( shallowWithIntl( - + ) ).toMatchSnapshot(); }); - it('renders an info message when provided', () => { - const mockHttp = createMockHttp(); - const mockWindow = {}; - const loginState = createLoginState(); - + it('renders an info message when provided.', () => { const wrapper = shallowWithIntl( - ); @@ -77,33 +45,67 @@ describe('BasicLoginForm', () => { }); it('renders an invalid credentials message', async () => { - const mockHttp = createMockHttp({ simulateError: true }); - const mockWindow = {}; - const loginState = createLoginState(); - - const wrapper = mountWithIntl( - - ); + const mockHTTP = coreMock.createStart({ basePath: '/some-base-path' }).http; + mockHTTP.post.mockRejectedValue({ response: { status: 401 } }); + + const wrapper = mountWithIntl(); wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); wrapper.find(EuiButton).simulate('click'); - // Wait for ajax + rerender - await Promise.resolve(); - wrapper.update(); - await Promise.resolve(); - wrapper.update(); + await act(async () => { + await nextTick(); + wrapper.update(); + }); expect(wrapper.find(EuiCallOut).props().title).toEqual( `Invalid username or password. Please try again.` ); }); + + it('renders unknown error message', async () => { + const mockHTTP = coreMock.createStart({ basePath: '/some-base-path' }).http; + mockHTTP.post.mockRejectedValue({ response: { status: 500 } }); + + const wrapper = mountWithIntl(); + + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); + wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); + wrapper.find(EuiButton).simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(EuiCallOut).props().title).toEqual(`Oops! Error. Try again.`); + }); + + it('properly redirects after successful login', async () => { + window.location.href = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + const mockHTTP = coreMock.createStart({ basePath: '/some-base-path' }).http; + mockHTTP.post.mockResolvedValue({}); + + const wrapper = mountWithIntl(); + + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username1' } }); + wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password1' } }); + wrapper.find(EuiButton).simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(mockHTTP.post).toHaveBeenCalledTimes(1); + expect(mockHTTP.post).toHaveBeenCalledWith('/internal/security/login', { + body: JSON.stringify({ username: 'username1', password: 'password1' }), + }); + + expect(window.location.href).toBe('/some-base-path/app/kibana#/home?_g=()'); + expect(wrapper.find(EuiCallOut).exists()).toBe(false); + }); }); diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx index d5658cc297c26..7302ee9bf9851 100644 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx @@ -4,20 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiCallOut, EuiFieldText, EuiFormRow, EuiPanel, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React, { ChangeEvent, Component, FormEvent, Fragment, MouseEvent } from 'react'; import ReactMarkdown from 'react-markdown'; -import { EuiText } from '@elastic/eui'; -import { LoginState } from '../../login_state'; +import { + EuiButton, + EuiCallOut, + EuiFieldText, + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { HttpStart, IHttpFetchError } from 'src/core/public'; +import { parseNext } from '../../../../../common/parse_next'; interface Props { - http: any; - window: any; + http: HttpStart; infoMessage?: string; - loginState: LoginState; - next: string; - intl: InjectedIntl; loginAssistanceMessage: string; } @@ -29,7 +34,7 @@ interface State { message: string; } -class BasicLoginFormUI extends Component { +export class BasicLoginForm extends Component { public state = { hasError: false, isLoading: false, @@ -175,7 +180,7 @@ class BasicLoginFormUI extends Component { }); }; - private submit = (e: MouseEvent | FormEvent) => { + private submit = async (e: MouseEvent | FormEvent) => { e.preventDefault(); if (!this.isFormValid()) { @@ -187,34 +192,28 @@ class BasicLoginFormUI extends Component { message: '', }); - const { http, window, next, intl } = this.props; - + const { http } = this.props; const { username, password } = this.state; - http.post('./internal/security/login', { username, password }).then( - () => (window.location.href = next), - (error: any) => { - const { statusCode = 500 } = error.data || {}; - - let message = intl.formatMessage({ - id: 'xpack.security.login.basicLoginForm.unknownErrorMessage', - defaultMessage: 'Oops! Error. Try again.', - }); - if (statusCode === 401) { - message = intl.formatMessage({ - id: 'xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage', - defaultMessage: 'Invalid username or password. Please try again.', - }); - } - - this.setState({ - hasError: true, - message, - isLoading: false, - }); - } - ); + try { + await http.post('/internal/security/login', { body: JSON.stringify({ username, password }) }); + window.location.href = parseNext(window.location.href, http.basePath.serverBasePath); + } catch (error) { + const message = + (error as IHttpFetchError).response?.status === 401 + ? i18n.translate( + 'xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage', + { defaultMessage: 'Invalid username or password. Please try again.' } + ) + : i18n.translate('xpack.security.login.basicLoginForm.unknownErrorMessage', { + defaultMessage: 'Oops! Error. Try again.', + }); + + this.setState({ + hasError: true, + message, + isLoading: false, + }); + } }; } - -export const BasicLoginForm = injectI18n(BasicLoginFormUI); diff --git a/x-pack/plugins/security/public/authentication/login/components/index.ts b/x-pack/plugins/security/public/authentication/login/components/index.ts index e3ce25c0f46fe..5f267f7c4caa2 100644 --- a/x-pack/plugins/security/public/authentication/login/components/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { LoginPage } from './login_page'; +export { BasicLoginForm } from './basic_login_form'; +export { DisabledLoginForm } from './disabled_login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/index.ts b/x-pack/plugins/security/public/authentication/login/index.ts index e3ce25c0f46fe..c965dced799eb 100644 --- a/x-pack/plugins/security/public/authentication/login/index.ts +++ b/x-pack/plugins/security/public/authentication/login/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { LoginPage } from './login_page'; +export { loginApp } from './login_app'; diff --git a/x-pack/plugins/security/public/authentication/login/login_app.test.ts b/x-pack/plugins/security/public/authentication/login/login_app.test.ts new file mode 100644 index 0000000000000..9b5e20d81d54e --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('./login_page'); + +import { AppMount } from 'src/core/public'; +import { loginApp } from './login_app'; + +import { coreMock } from '../../../../../../src/core/public/mocks'; + +describe('loginApp', () => { + it('properly registers application', () => { + const coreSetupMock = coreMock.createSetup(); + + loginApp.create({ + ...coreSetupMock, + config: { loginAssistanceMessage: '' }, + }); + + expect(coreSetupMock.http.anonymousPaths.register).toHaveBeenCalledTimes(1); + expect(coreSetupMock.http.anonymousPaths.register).toHaveBeenCalledWith('/login'); + + expect(coreSetupMock.application.register).toHaveBeenCalledTimes(1); + + const [[appRegistration]] = coreSetupMock.application.register.mock.calls; + expect(appRegistration).toEqual({ + id: 'login', + chromeless: true, + appRoute: '/login', + title: 'Login', + mount: expect.any(Function), + }); + }); + + it('properly renders application', async () => { + const coreSetupMock = coreMock.createSetup(); + const coreStartMock = coreMock.createStart(); + coreStartMock.injectedMetadata.getInjectedVar.mockReturnValue(true); + coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}]); + const containerMock = document.createElement('div'); + + loginApp.create({ + ...coreSetupMock, + config: { loginAssistanceMessage: 'some-message' }, + }); + + const [[{ mount }]] = coreSetupMock.application.register.mock.calls; + await (mount as AppMount)({ element: containerMock, appBasePath: '', onAppLeave: jest.fn() }); + + expect(coreStartMock.injectedMetadata.getInjectedVar).toHaveBeenCalledTimes(1); + expect(coreStartMock.injectedMetadata.getInjectedVar).toHaveBeenCalledWith('secureCookies'); + + const mockRenderApp = jest.requireMock('./login_page').renderLoginPage; + expect(mockRenderApp).toHaveBeenCalledTimes(1); + expect(mockRenderApp).toHaveBeenCalledWith(coreStartMock.i18n, containerMock, { + http: coreStartMock.http, + fatalErrors: coreStartMock.fatalErrors, + loginAssistanceMessage: 'some-message', + requiresSecureConnection: true, + }); + }); +}); diff --git a/x-pack/plugins/security/public/authentication/login/login_app.ts b/x-pack/plugins/security/public/authentication/login/login_app.ts new file mode 100644 index 0000000000000..e34ed27e8686d --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/login_app.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, AppMountParameters, HttpSetup } from 'src/core/public'; +import { ConfigType } from '../../config'; + +interface CreateDeps { + application: CoreSetup['application']; + http: HttpSetup; + getStartServices: CoreSetup['getStartServices']; + config: Pick; +} + +export const loginApp = Object.freeze({ + id: 'login', + create({ application, http, getStartServices, config }: CreateDeps) { + http.anonymousPaths.register('/login'); + application.register({ + id: this.id, + title: i18n.translate('xpack.security.loginAppTitle', { defaultMessage: 'Login' }), + chromeless: true, + appRoute: '/login', + async mount({ element }: AppMountParameters) { + const [[coreStart], { renderLoginPage }] = await Promise.all([ + getStartServices(), + import('./login_page'), + ]); + return renderLoginPage(coreStart.i18n, element, { + http: coreStart.http, + fatalErrors: coreStart.fatalErrors, + loginAssistanceMessage: config.loginAssistanceMessage, + requiresSecureConnection: coreStart.injectedMetadata.getInjectedVar( + 'secureCookies' + ) as boolean, + }); + }, + }); + }, +}); diff --git a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx index a0318d50a45e5..ac17dfa5870ef 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx @@ -4,29 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; import React from 'react'; -import { LoginLayout, LoginState } from '../../login_state'; +import { shallow } from 'enzyme'; +import { act } from '@testing-library/react'; +import { nextTick } from 'test_utils/enzyme_helpers'; +import { LoginState } from './login_state'; import { LoginPage } from './login_page'; - -const createMockHttp = ({ simulateError = false } = {}) => { - return { - post: jest.fn(async () => { - if (simulateError) { - // eslint-disable-next-line no-throw-literal - throw { - data: { - statusCode: 401, - }, - }; - } - - return { - statusCode: 200, - }; - }), - }; -}; +import { coreMock } from '../../../../../../src/core/public/mocks'; const createLoginState = (options?: Partial) => { return { @@ -38,96 +22,174 @@ const createLoginState = (options?: Partial) => { describe('LoginPage', () => { describe('disabled form states', () => { - it('renders as expected when secure cookies are required but not present', () => { - const props = { - http: createMockHttp(), - window: {}, - next: '', - loginState: createLoginState(), - isSecureConnection: false, - requiresSecureConnection: true, - loginAssistanceMessage: '', - }; - - expect(shallow()).toMatchSnapshot(); + it('renders as expected when secure cookies are required but not present', async () => { + const coreStartMock = coreMock.createStart(); + coreStartMock.http.get.mockResolvedValue(createLoginState()); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.get).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.get).toHaveBeenCalledWith('/internal/security/login_state'); + + expect(wrapper).toMatchSnapshot(); }); - it('renders as expected when a connection to ES is not available', () => { - const props = { - http: createMockHttp(), - window: {}, - next: '', - loginState: createLoginState({ - layout: 'error-es-unavailable', - }), - isSecureConnection: false, - requiresSecureConnection: false, - loginAssistanceMessage: '', - }; - - expect(shallow()).toMatchSnapshot(); + it('renders as expected when a connection to ES is not available', async () => { + const coreStartMock = coreMock.createStart(); + coreStartMock.http.get.mockResolvedValue( + createLoginState({ layout: 'error-es-unavailable' }) + ); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper).toMatchSnapshot(); }); - it('renders as expected when xpack is not available', () => { - const props = { - http: createMockHttp(), - window: {}, - next: '', - loginState: createLoginState({ - layout: 'error-xpack-unavailable', - }), - isSecureConnection: false, - requiresSecureConnection: false, - loginAssistanceMessage: '', - }; - - expect(shallow()).toMatchSnapshot(); + it('renders as expected when xpack is not available', async () => { + const coreStartMock = coreMock.createStart(); + coreStartMock.http.get.mockResolvedValue( + createLoginState({ layout: 'error-xpack-unavailable' }) + ); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper).toMatchSnapshot(); }); - it('renders as expected when an unknown loginState layout is provided', () => { - const props = { - http: createMockHttp(), - window: {}, - next: '', - loginState: createLoginState({ - layout: 'error-asdf-asdf-unknown' as LoginLayout, - }), - isSecureConnection: false, - requiresSecureConnection: false, - loginAssistanceMessage: '', - }; - - expect(shallow()).toMatchSnapshot(); + it('renders as expected when an unknown loginState layout is provided', async () => { + const coreStartMock = coreMock.createStart(); + coreStartMock.http.get.mockResolvedValue( + createLoginState({ layout: 'error-asdf-asdf-unknown' as any }) + ); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper).toMatchSnapshot(); }); - it('renders as expected when loginAssistanceMessage is set', () => { - const props = { - http: createMockHttp(), - window: {}, - next: '', - loginState: createLoginState(), - isSecureConnection: false, - requiresSecureConnection: false, - loginAssistanceMessage: 'This is an *important* message', - }; - - expect(shallow()).toMatchSnapshot(); + it('renders as expected when loginAssistanceMessage is set', async () => { + const coreStartMock = coreMock.createStart(); + coreStartMock.http.get.mockResolvedValue(createLoginState()); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper).toMatchSnapshot(); }); }); describe('enabled form state', () => { - it('renders as expected', () => { - const props = { - http: createMockHttp(), - window: {}, - next: '', - loginState: createLoginState(), - isSecureConnection: false, - requiresSecureConnection: false, - loginAssistanceMessage: '', - }; - - expect(shallow()).toMatchSnapshot(); + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { href: 'http://some-host/bar', protocol: 'http' }, + writable: true, + }); + }); + + afterAll(() => { + delete (window as any).location; + }); + + it('renders as expected', async () => { + const coreStartMock = coreMock.createStart(); + coreStartMock.http.get.mockResolvedValue(createLoginState()); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders as expected when info message is set', async () => { + const coreStartMock = coreMock.createStart(); + coreStartMock.http.get.mockResolvedValue(createLoginState()); + window.location.href = 'http://some-host/bar?msg=SESSION_EXPIRED'; + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper).toMatchSnapshot(); }); }); }); diff --git a/x-pack/plugins/security/public/authentication/login/login_page.tsx b/x-pack/plugins/security/public/authentication/login/login_page.tsx index 8035789a30e9d..848751aa03352 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.tsx @@ -5,45 +5,81 @@ */ import React, { Component } from 'react'; - -import { FormattedMessage } from '@kbn/i18n/react'; - -import { - // @ts-ignore - EuiCard, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui'; +import ReactDOM from 'react-dom'; import classNames from 'classnames'; -import { LoginState } from '../../login_state'; -import { BasicLoginForm } from '../basic_login_form'; -import { DisabledLoginForm } from '../disabled_login_form'; +import { BehaviorSubject } from 'rxjs'; +import { parse } from 'url'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CoreStart, FatalErrorsStart, HttpStart } from 'src/core/public'; +import { LoginLayout } from '../../../common/licensing'; +import { BasicLoginForm, DisabledLoginForm } from './components'; +import { LoginState } from './login_state'; interface Props { - http: any; - window: any; - next: string; - infoMessage?: string; - loginState: LoginState; - isSecureConnection: boolean; - requiresSecureConnection: boolean; + http: HttpStart; + fatalErrors: FatalErrorsStart; loginAssistanceMessage: string; + requiresSecureConnection: boolean; +} + +interface State { + loginState: LoginState | null; } -export class LoginPage extends Component { +const infoMessageMap = new Map([ + [ + 'SESSION_EXPIRED', + i18n.translate('xpack.security.login.sessionExpiredDescription', { + defaultMessage: 'Your session has timed out. Please log in again.', + }), + ], + [ + 'LOGGED_OUT', + i18n.translate('xpack.security.login.loggedOutDescription', { + defaultMessage: 'You have logged out of Kibana.', + }), + ], +]); + +export class LoginPage extends Component { + state = { loginState: null }; + + public async componentDidMount() { + const loadingCount$ = new BehaviorSubject(1); + this.props.http.addLoadingCountSource(loadingCount$.asObservable()); + + try { + this.setState({ loginState: await this.props.http.get('/internal/security/login_state') }); + } catch (err) { + this.props.fatalErrors.add(err); + } + + loadingCount$.next(0); + loadingCount$.complete(); + } + public render() { - const allowLogin = this.allowLogin(); + const loginState = this.state.loginState; + if (!loginState) { + return null; + } + + const isSecureConnection = !!window.location.protocol.match(/^https/); + const { allowLogin, layout } = loginState; + + const loginIsSupported = + this.props.requiresSecureConnection && !isSecureConnection + ? false + : allowLogin && layout === 'form'; const contentHeaderClasses = classNames('loginWelcome__content', 'eui-textCenter', { - ['loginWelcome__contentDisabledForm']: !allowLogin, + ['loginWelcome__contentDisabledForm']: !loginIsSupported, }); const contentBodyClasses = classNames('loginWelcome__content', 'loginWelcome-body', { - ['loginWelcome__contentDisabledForm']: !allowLogin, + ['loginWelcome__contentDisabledForm']: !loginIsSupported, }); return ( @@ -75,23 +111,21 @@ export class LoginPage extends Component {
- {this.getLoginForm()} + {this.getLoginForm({ isSecureConnection, layout })}
); } - private allowLogin = () => { - if (this.props.requiresSecureConnection && !this.props.isSecureConnection) { - return false; - } - - return this.props.loginState.allowLogin && this.props.loginState.layout === 'form'; - }; - - private getLoginForm = () => { - if (this.props.requiresSecureConnection && !this.props.isSecureConnection) { + private getLoginForm = ({ + isSecureConnection, + layout, + }: { + isSecureConnection: boolean; + layout: LoginLayout; + }) => { + if (this.props.requiresSecureConnection && !isSecureConnection) { return ( { ); } - const layout = this.props.loginState.layout; switch (layout) { case 'form': - return ; + return ( + + ); case 'error-es-unavailable': return ( { } }; } + +export function renderLoginPage(i18nStart: CoreStart['i18n'], element: Element, props: Props) { + ReactDOM.render( + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +} diff --git a/x-pack/plugins/security/public/authentication/login/login_state.ts b/x-pack/plugins/security/public/authentication/login/login_state.ts index b1eb3d61fe5f3..6ca38296706fe 100644 --- a/x-pack/plugins/security/public/authentication/login/login_state.ts +++ b/x-pack/plugins/security/public/authentication/login/login_state.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export type LoginLayout = 'form' | 'error-es-unavailable' | 'error-xpack-unavailable'; +import { LoginLayout } from '../../../common/licensing'; export interface LoginState { layout: LoginLayout; diff --git a/x-pack/legacy/plugins/security/public/views/login/index.ts b/x-pack/plugins/security/public/authentication/logout/index.ts similarity index 85% rename from x-pack/legacy/plugins/security/public/views/login/index.ts rename to x-pack/plugins/security/public/authentication/logout/index.ts index b2de507d5ee12..981811ab21eed 100644 --- a/x-pack/legacy/plugins/security/public/views/login/index.ts +++ b/x-pack/plugins/security/public/authentication/logout/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import './login'; +export { logoutApp } from './logout_app'; diff --git a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts new file mode 100644 index 0000000000000..83437aa79f31e --- /dev/null +++ b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AppMount } from 'src/core/public'; +import { logoutApp } from './logout_app'; + +import { coreMock } from '../../../../../../src/core/public/mocks'; + +describe('logoutApp', () => { + beforeAll(() => { + Object.defineProperty(window, 'sessionStorage', { + value: { clear: jest.fn() }, + writable: true, + }); + Object.defineProperty(window, 'location', { + value: { href: 'https://some-host/bar?arg=true', search: '?arg=true' }, + writable: true, + }); + }); + + afterAll(() => { + delete (window as any).sessionStorage; + delete (window as any).location; + }); + + it('properly registers application', () => { + const coreSetupMock = coreMock.createSetup(); + + logoutApp.create(coreSetupMock); + + expect(coreSetupMock.http.anonymousPaths.register).toHaveBeenCalledTimes(1); + expect(coreSetupMock.http.anonymousPaths.register).toHaveBeenCalledWith('/logout'); + + expect(coreSetupMock.application.register).toHaveBeenCalledTimes(1); + + const [[appRegistration]] = coreSetupMock.application.register.mock.calls; + expect(appRegistration).toEqual({ + id: 'logout', + chromeless: true, + appRoute: '/logout', + title: 'Logout', + mount: expect.any(Function), + }); + }); + + it('properly mounts application', async () => { + const coreSetupMock = coreMock.createSetup({ basePath: '/mock-base-path' }); + const containerMock = document.createElement('div'); + + logoutApp.create(coreSetupMock); + + const [[{ mount }]] = coreSetupMock.application.register.mock.calls; + await (mount as AppMount)({ element: containerMock, appBasePath: '', onAppLeave: jest.fn() }); + + expect(window.sessionStorage.clear).toHaveBeenCalledTimes(1); + expect(window.location.href).toBe('/mock-base-path/api/security/logout?arg=true'); + }); +}); diff --git a/x-pack/plugins/security/public/authentication/logout/logout_app.ts b/x-pack/plugins/security/public/authentication/logout/logout_app.ts new file mode 100644 index 0000000000000..6ea03b705f6cd --- /dev/null +++ b/x-pack/plugins/security/public/authentication/logout/logout_app.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, HttpSetup } from 'src/core/public'; + +interface CreateDeps { + application: CoreSetup['application']; + http: HttpSetup; +} + +export const logoutApp = Object.freeze({ + id: 'logout', + create({ application, http }: CreateDeps) { + http.anonymousPaths.register('/logout'); + application.register({ + id: this.id, + title: i18n.translate('xpack.security.logoutAppTitle', { defaultMessage: 'Logout' }), + chromeless: true, + appRoute: '/logout', + async mount() { + window.sessionStorage.clear(); + + // Redirect user to the server logout endpoint to complete logout. + window.location.href = http.basePath.prepend( + `/api/security/logout${window.location.search}` + ); + + return () => {}; + }, + }); + }, +}); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/index.ts b/x-pack/plugins/security/public/authentication/overwritten_session/index.ts index f3ba8a6b9d7c5..a9552a1157a19 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/index.ts +++ b/x-pack/plugins/security/public/authentication/overwritten_session/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import './overwritten_session'; +export { overwrittenSessionApp } from './overwritten_session_app'; diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts new file mode 100644 index 0000000000000..fc9677076f565 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('./overwritten_session_page'); + +import { AppMount } from 'src/core/public'; +import { overwrittenSessionApp } from './overwritten_session_app'; + +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { securityMock } from '../../mocks'; + +describe('overwrittenSessionApp', () => { + it('properly registers application', () => { + const coreSetupMock = coreMock.createSetup(); + + overwrittenSessionApp.create({ + application: coreSetupMock.application, + getStartServices: coreSetupMock.getStartServices, + authc: securityMock.createSetup().authc, + }); + + expect(coreSetupMock.application.register).toHaveBeenCalledTimes(1); + + const [[appRegistration]] = coreSetupMock.application.register.mock.calls; + expect(appRegistration).toEqual({ + id: 'overwritten_session', + title: 'Overwritten Session', + chromeless: true, + appRoute: '/overwritten_session', + mount: expect.any(Function), + }); + }); + + it('properly sets breadcrumbs and renders application', async () => { + const coreSetupMock = coreMock.createSetup(); + const coreStartMock = coreMock.createStart(); + coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}]); + + const authcMock = securityMock.createSetup().authc; + const containerMock = document.createElement('div'); + + overwrittenSessionApp.create({ + application: coreSetupMock.application, + getStartServices: coreSetupMock.getStartServices, + authc: authcMock, + }); + + const [[{ mount }]] = coreSetupMock.application.register.mock.calls; + await (mount as AppMount)({ element: containerMock, appBasePath: '', onAppLeave: jest.fn() }); + + const mockRenderApp = jest.requireMock('./overwritten_session_page') + .renderOverwrittenSessionPage; + expect(mockRenderApp).toHaveBeenCalledTimes(1); + expect(mockRenderApp).toHaveBeenCalledWith(coreStartMock.i18n, containerMock, { + authc: authcMock, + basePath: coreStartMock.http.basePath, + }); + }); +}); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts new file mode 100644 index 0000000000000..0618884fee5e0 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, AppMountParameters } from 'src/core/public'; +import { AuthenticationServiceSetup } from '../authentication_service'; + +interface CreateDeps { + application: CoreSetup['application']; + authc: AuthenticationServiceSetup; + getStartServices: CoreSetup['getStartServices']; +} + +export const overwrittenSessionApp = Object.freeze({ + id: 'overwritten_session', + create({ application, authc, getStartServices }: CreateDeps) { + application.register({ + id: this.id, + title: i18n.translate('xpack.security.overwrittenSessionAppTitle', { + defaultMessage: 'Overwritten Session', + }), + chromeless: true, + appRoute: '/overwritten_session', + async mount({ element }: AppMountParameters) { + const [[coreStart], { renderOverwrittenSessionPage }] = await Promise.all([ + getStartServices(), + import('./overwritten_session_page'), + ]); + return renderOverwrittenSessionPage(coreStart.i18n, element, { + authc, + basePath: coreStart.http.basePath, + }); + }, + }); + }, +}); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx new file mode 100644 index 0000000000000..1093957761d1c --- /dev/null +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import ReactDOM from 'react-dom'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CoreStart, IBasePath } from 'src/core/public'; +import { AuthenticationServiceSetup } from '../authentication_service'; +import { AuthenticationStatePage } from '../components'; + +interface Props { + basePath: IBasePath; + authc: AuthenticationServiceSetup; +} + +export function OverwrittenSessionPage({ authc, basePath }: Props) { + const [username, setUsername] = useState(null); + useEffect(() => { + authc.getCurrentUser().then(user => setUsername(user.username)); + }, [authc]); + + if (username == null) { + return null; + } + + return ( + + } + > + + + + + ); +} + +export function renderOverwrittenSessionPage( + i18nStart: CoreStart['i18n'], + element: Element, + props: Props +) { + ReactDOM.render( + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +} diff --git a/x-pack/legacy/plugins/security/public/views/logged_out/index.js b/x-pack/plugins/security/public/config.ts similarity index 78% rename from x-pack/legacy/plugins/security/public/views/logged_out/index.js rename to x-pack/plugins/security/public/config.ts index 3a2281bd6beee..56bd02976c1b4 100644 --- a/x-pack/legacy/plugins/security/public/views/logged_out/index.js +++ b/x-pack/plugins/security/public/config.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import './logged_out'; +export interface ConfigType { + loginAssistanceMessage: string; +} diff --git a/x-pack/plugins/security/public/index.scss b/x-pack/plugins/security/public/index.scss index 1bdb8cc178fdf..999639ba22eb7 100644 --- a/x-pack/plugins/security/public/index.scss +++ b/x-pack/plugins/security/public/index.scss @@ -1,4 +1,7 @@ $secFormWidth: 460px; +// Authentication styles +@import './authentication/index'; + // Management styles @import './management/index'; diff --git a/x-pack/plugins/security/public/index.ts b/x-pack/plugins/security/public/index.ts index 1c525dc6b9187..fdb8b544d61d3 100644 --- a/x-pack/plugins/security/public/index.ts +++ b/x-pack/plugins/security/public/index.ts @@ -5,7 +5,7 @@ */ import './index.scss'; -import { PluginInitializer } from 'src/core/public'; +import { PluginInitializer, PluginInitializerContext } from 'src/core/public'; import { SecurityPlugin, SecurityPluginSetup, SecurityPluginStart } from './plugin'; export { SecurityPluginSetup, SecurityPluginStart }; @@ -13,5 +13,6 @@ export { SessionInfo } from './types'; export { AuthenticatedUser } from '../common/model'; export { SecurityLicense, SecurityLicenseFeatures } from '../common/licensing'; -export const plugin: PluginInitializer = () => - new SecurityPlugin(); +export const plugin: PluginInitializer = ( + initializerContext: PluginInitializerContext +) => new SecurityPlugin(initializerContext); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts index cd66868edd700..66731cf19006d 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts @@ -38,6 +38,7 @@ describe('SecurityNavControlService', () => { navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, authc: mockSecuritySetup.authc, + logoutUrl: '/some/logout/url', }); const coreStart = coreMock.createStart(); @@ -100,6 +101,7 @@ describe('SecurityNavControlService', () => { navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, authc: securityMock.createSetup().authc, + logoutUrl: '/some/logout/url', }); const coreStart = coreMock.createStart(); @@ -119,6 +121,7 @@ describe('SecurityNavControlService', () => { navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, authc: securityMock.createSetup().authc, + logoutUrl: '/some/logout/url', }); const coreStart = coreMock.createStart(); @@ -135,6 +138,7 @@ describe('SecurityNavControlService', () => { navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, authc: securityMock.createSetup().authc, + logoutUrl: '/some/logout/url', }); const coreStart = coreMock.createStart(); @@ -156,6 +160,7 @@ describe('SecurityNavControlService', () => { navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, authc: securityMock.createSetup().authc, + logoutUrl: '/some/logout/url', }); const coreStart = coreMock.createStart(); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index 813304148ec77..aa3ec2e47469d 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -15,6 +15,7 @@ import { AuthenticationServiceSetup } from '../authentication'; interface SetupDeps { securityLicense: SecurityLicense; authc: AuthenticationServiceSetup; + logoutUrl: string; } interface StartDeps { @@ -24,14 +25,16 @@ interface StartDeps { export class SecurityNavControlService { private securityLicense!: SecurityLicense; private authc!: AuthenticationServiceSetup; + private logoutUrl!: string; private navControlRegistered!: boolean; private securityFeaturesSubscription?: Subscription; - public setup({ securityLicense, authc }: SetupDeps) { + public setup({ securityLicense, authc, logoutUrl }: SetupDeps) { this.securityLicense = securityLicense; this.authc = authc; + this.logoutUrl = logoutUrl; } public start({ core }: StartDeps) { @@ -65,12 +68,10 @@ export class SecurityNavControlService { mount: (el: HTMLElement) => { const I18nContext = core.i18n.Context; - const logoutUrl = core.injectedMetadata.getInjectedVar('logoutUrl') as string; - const props = { user: currentUserPromise, - editProfileUrl: core.http.basePath.prepend('/app/kibana#/account'), - logoutUrl, + editProfileUrl: core.http.basePath.prepend('/security/account'), + logoutUrl: this.logoutUrl, }; ReactDOM.render( diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx new file mode 100644 index 0000000000000..d425663522c08 --- /dev/null +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { CoreSetup } from 'src/core/public'; +import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { SessionTimeout } from './session'; +import { PluginStartDependencies, SecurityPlugin } from './plugin'; + +import { coreMock } from '../../../../src/core/public/mocks'; +import { managementPluginMock } from '../../../../src/plugins/management/public/mocks'; +import { licensingMock } from '../../licensing/public/mocks'; +import { ManagementService } from './management'; + +describe('Security Plugin', () => { + describe('#setup', () => { + it('should be able to setup if optional plugins are not available', () => { + const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); + expect( + plugin.setup( + coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup< + PluginStartDependencies + >, + { licensing: licensingMock.createSetup() } + ) + ).toEqual({ + __legacyCompat: { logoutUrl: '/some-base-path/logout', tenant: '/some-base-path' }, + authc: { getCurrentUser: expect.any(Function) }, + license: { + isEnabled: expect.any(Function), + getFeatures: expect.any(Function), + features$: expect.any(Observable), + }, + sessionTimeout: expect.any(SessionTimeout), + }); + }); + + it('setups Management Service if `management` plugin is available', () => { + const coreSetupMock = coreMock.createSetup({ basePath: '/some-base-path' }); + const setupManagementServiceMock = jest + .spyOn(ManagementService.prototype, 'setup') + .mockImplementation(() => {}); + const managementSetupMock = managementPluginMock.createSetupContract(); + + const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); + + plugin.setup(coreSetupMock as CoreSetup, { + licensing: licensingMock.createSetup(), + management: managementSetupMock, + }); + + expect(setupManagementServiceMock).toHaveBeenCalledTimes(1); + expect(setupManagementServiceMock).toHaveBeenCalledWith({ + authc: { getCurrentUser: expect.any(Function) }, + license: { + isEnabled: expect.any(Function), + getFeatures: expect.any(Function), + features$: expect.any(Observable), + }, + management: managementSetupMock, + fatalErrors: coreSetupMock.fatalErrors, + getStartServices: coreSetupMock.getStartServices, + }); + }); + }); + + describe('#start', () => { + it('should be able to setup if optional plugins are not available', () => { + const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); + plugin.setup( + coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup, + { licensing: licensingMock.createSetup() } + ); + + expect( + plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { + data: {} as DataPublicPluginStart, + }) + ).toBeUndefined(); + }); + + it('starts Management Service if `management` plugin is available', () => { + jest.spyOn(ManagementService.prototype, 'setup').mockImplementation(() => {}); + const startManagementServiceMock = jest + .spyOn(ManagementService.prototype, 'start') + .mockImplementation(() => {}); + const managementSetupMock = managementPluginMock.createSetupContract(); + const managementStartMock = managementPluginMock.createStartContract(); + + const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); + + plugin.setup( + coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup, + { + licensing: licensingMock.createSetup(), + management: managementSetupMock, + } + ); + + plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { + data: {} as DataPublicPluginStart, + management: managementStartMock, + }); + + expect(startManagementServiceMock).toHaveBeenCalledTimes(1); + expect(startManagementServiceMock).toHaveBeenCalledWith({ management: managementStartMock }); + }); + }); + + describe('#stop', () => { + it('does not fail if called before `start`.', () => { + const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); + expect(() => plugin.stop()).not.toThrow(); + }); + + it('does not fail if called during normal plugin life cycle.', () => { + const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); + + plugin.setup( + coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup, + { licensing: licensingMock.createSetup() } + ); + + plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { + data: {} as DataPublicPluginStart, + }); + + expect(() => plugin.stop()).not.toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 467f86bd1ac69..e9fe017de134a 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -4,9 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Plugin, CoreSetup, CoreStart } from 'src/core/public'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import { + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, +} from '../../../../src/core/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { FeatureCatalogueCategory, @@ -15,17 +19,18 @@ import { import { LicensingPluginSetup } from '../../licensing/public'; import { ManagementSetup, ManagementStart } from '../../../../src/plugins/management/public'; import { + ISessionTimeout, SessionExpired, SessionTimeout, - ISessionTimeout, SessionTimeoutHttpInterceptor, UnauthorizedResponseHttpInterceptor, } from './session'; import { SecurityLicenseService } from '../common/licensing'; import { SecurityNavControlService } from './nav_control'; -import { AccountManagementPage } from './account_management'; import { AuthenticationService, AuthenticationServiceSetup } from './authentication'; -import { ManagementService, UserAPIClient } from './management'; +import { ConfigType } from './config'; +import { ManagementService } from './management'; +import { accountManagementApp } from './account_management'; export interface PluginSetupDependencies { licensing: LicensingPluginSetup; @@ -47,23 +52,27 @@ export class SecurityPlugin PluginStartDependencies > { private sessionTimeout!: ISessionTimeout; + private readonly authenticationService = new AuthenticationService(); private readonly navControlService = new SecurityNavControlService(); private readonly securityLicenseService = new SecurityLicenseService(); private readonly managementService = new ManagementService(); private authc!: AuthenticationServiceSetup; + private readonly config: ConfigType; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.config = this.initializerContext.config.get(); + } public setup( core: CoreSetup, { home, licensing, management }: PluginSetupDependencies ) { - const { http, notifications, injectedMetadata } = core; + const { http, notifications } = core; const { anonymousPaths } = http; - anonymousPaths.register('/login'); - anonymousPaths.register('/logout'); - anonymousPaths.register('/logged_out'); - const tenant = injectedMetadata.getInjectedVar('session.tenant', '') as string; - const logoutUrl = injectedMetadata.getInjectedVar('logoutUrl') as string; + const logoutUrl = `${core.http.basePath.serverBasePath}/logout`; + const tenant = core.http.basePath.serverBasePath; + const sessionExpired = new SessionExpired(logoutUrl, tenant); http.intercept(new UnauthorizedResponseHttpInterceptor(sessionExpired, anonymousPaths)); this.sessionTimeout = new SessionTimeout(notifications, sessionExpired, http, tenant); @@ -71,11 +80,23 @@ export class SecurityPlugin const { license } = this.securityLicenseService.setup({ license$: licensing.license$ }); - this.authc = new AuthenticationService().setup({ http: core.http }); + this.authc = this.authenticationService.setup({ + application: core.application, + config: this.config, + getStartServices: core.getStartServices, + http: core.http, + }); this.navControlService.setup({ securityLicense: license, authc: this.authc, + logoutUrl, + }); + + accountManagementApp.create({ + authc: this.authc, + application: core.application, + getStartServices: core.getStartServices, }); if (management) { @@ -109,6 +130,7 @@ export class SecurityPlugin authc: this.authc, sessionTimeout: this.sessionTimeout, license, + __legacyCompat: { logoutUrl, tenant }, }; } @@ -119,26 +141,13 @@ export class SecurityPlugin if (management) { this.managementService.start({ management }); } - - return { - __legacyCompat: { - account_management: { - AccountManagementPage: () => ( - - - - ), - }, - }, - }; } public stop() { - this.sessionTimeout.stop(); + if (this.sessionTimeout) { + this.sessionTimeout.stop(); + } + this.navControlService.stop(); this.securityLicenseService.stop(); this.managementService.stop(); diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index f7374eedb5520..770d1ff306277 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -13,57 +13,69 @@ import { createConfig$, ConfigSchema } from './config'; describe('config schema', () => { it('generates proper defaults', () => { expect(ConfigSchema.validate({})).toMatchInlineSnapshot(` - Object { - "authc": Object { - "providers": Array [ - "basic", - ], - }, - "cookieName": "sid", - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "loginAssistanceMessage": "", - "secureCookies": false, - "session": Object { - "idleTimeout": null, - "lifespan": null, - }, - } - `); + Object { + "audit": Object { + "enabled": false, + }, + "authc": Object { + "providers": Array [ + "basic", + ], + }, + "cookieName": "sid", + "enabled": true, + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "loginAssistanceMessage": "", + "secureCookies": false, + "session": Object { + "idleTimeout": null, + "lifespan": null, + }, + } + `); expect(ConfigSchema.validate({}, { dist: false })).toMatchInlineSnapshot(` - Object { - "authc": Object { - "providers": Array [ - "basic", - ], - }, - "cookieName": "sid", - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "loginAssistanceMessage": "", - "secureCookies": false, - "session": Object { - "idleTimeout": null, - "lifespan": null, - }, - } - `); + Object { + "audit": Object { + "enabled": false, + }, + "authc": Object { + "providers": Array [ + "basic", + ], + }, + "cookieName": "sid", + "enabled": true, + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "loginAssistanceMessage": "", + "secureCookies": false, + "session": Object { + "idleTimeout": null, + "lifespan": null, + }, + } + `); expect(ConfigSchema.validate({}, { dist: true })).toMatchInlineSnapshot(` - Object { - "authc": Object { - "providers": Array [ - "basic", - ], - }, - "cookieName": "sid", - "loginAssistanceMessage": "", - "secureCookies": false, - "session": Object { - "idleTimeout": null, - "lifespan": null, - }, - } - `); + Object { + "audit": Object { + "enabled": false, + }, + "authc": Object { + "providers": Array [ + "basic", + ], + }, + "cookieName": "sid", + "enabled": true, + "loginAssistanceMessage": "", + "secureCookies": false, + "session": Object { + "idleTimeout": null, + "lifespan": null, + }, + } + `); }); it('should throw error if xpack.security.encryptionKey is less than 32 characters', () => { diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index db8c48f314d7c..e1d2747324493 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -24,36 +24,36 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type) = schema.never() ); -export const ConfigSchema = schema.object( - { - loginAssistanceMessage: schema.string({ defaultValue: '' }), - cookieName: schema.string({ defaultValue: 'sid' }), - encryptionKey: schema.conditional( - schema.contextRef('dist'), - true, - schema.maybe(schema.string({ minLength: 32 })), - schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) +export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), + loginAssistanceMessage: schema.string({ defaultValue: '' }), + cookieName: schema.string({ defaultValue: 'sid' }), + encryptionKey: schema.conditional( + schema.contextRef('dist'), + true, + schema.maybe(schema.string({ minLength: 32 })), + schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) + ), + session: schema.object({ + idleTimeout: schema.nullable(schema.duration()), + lifespan: schema.nullable(schema.duration()), + }), + secureCookies: schema.boolean({ defaultValue: false }), + authc: schema.object({ + providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }), + oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })), + saml: providerOptionsSchema( + 'saml', + schema.object({ + realm: schema.string(), + maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), + }) ), - session: schema.object({ - idleTimeout: schema.nullable(schema.duration()), - lifespan: schema.nullable(schema.duration()), - }), - secureCookies: schema.boolean({ defaultValue: false }), - authc: schema.object({ - providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }), - oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })), - saml: providerOptionsSchema( - 'saml', - schema.object({ - realm: schema.string(), - maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), - }) - ), - }), - }, - // This option should be removed as soon as we entirely migrate config from legacy Security plugin. - { allowUnknowns: true } -); + }), + audit: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), +}); export function createConfig$(context: PluginInitializerContext, isTLSEnabled: boolean) { return context.config.create>().pipe( diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index c0e86b289fe54..2d3808ec27bc1 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -33,6 +33,9 @@ export const config: PluginConfigDescriptor> = { rename('sessionTimeout', 'session.idleTimeout'), unused('authorization.legacyFallback.enabled'), ], + exposeToBrowser: { + loginAssistanceMessage: true, + }, }; export const plugin: PluginInitializer< RecursiveReadonly, diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 56aad4ece3e95..6d974e5e73c7a 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -49,8 +49,6 @@ describe('Security Plugin', () => { Object { "__legacyCompat": Object { "config": Object { - "cookieName": "sid", - "loginAssistanceMessage": undefined, "secureCookies": true, }, "license": Object { diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 328f2917fd550..7d65fc3333e2e 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -65,11 +65,7 @@ export interface SecurityPluginSetup { registerLegacyAPI: (legacyAPI: LegacyAPI) => void; registerPrivilegesWithCluster: () => void; license: SecurityLicense; - config: RecursiveReadonly<{ - secureCookies: boolean; - cookieName: string; - loginAssistanceMessage: string; - }>; + config: RecursiveReadonly<{ secureCookies: boolean }>; }; } @@ -161,6 +157,7 @@ export class Plugin { authc, authz, csp: core.http.csp, + license, }); return deepFreeze({ @@ -187,13 +184,8 @@ export class Plugin { license, - // We should stop exposing this config as soon as only new platform plugin consumes it. The only - // exception may be `sessionTimeout` as other parts of the app may want to know it. - config: { - loginAssistanceMessage: config.loginAssistanceMessage, - secureCookies: config.secureCookies, - cookieName: config.cookieName, - }, + // We should stop exposing this config as soon as only new platform plugin consumes it. + config: { secureCookies: config.secureCookies }, }, }); } diff --git a/x-pack/plugins/security/server/routes/authentication/basic.test.ts b/x-pack/plugins/security/server/routes/authentication/basic.test.ts index be17b3e29f854..df12646ffa8fe 100644 --- a/x-pack/plugins/security/server/routes/authentication/basic.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/basic.test.ts @@ -14,26 +14,20 @@ import { } from '../../../../../../src/core/server'; import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; import { Authentication, AuthenticationResult } from '../../authentication'; -import { ConfigType } from '../../config'; import { defineBasicRoutes } from './basic'; -import { - elasticsearchServiceMock, - httpServerMock, - httpServiceMock, - loggingServiceMock, -} from '../../../../../../src/core/server/mocks'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { authenticationMock } from '../../authentication/index.mock'; -import { authorizationMock } from '../../authorization/index.mock'; +import { routeDefinitionParamsMock } from '../index.mock'; describe('Basic authentication routes', () => { let router: jest.Mocked; let authc: jest.Mocked; let mockContext: RequestHandlerContext; beforeEach(() => { - router = httpServiceMock.createRouter(); - authc = authenticationMock.create(); + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + authc = routeParamsMock.authc; mockContext = ({ licensing: { @@ -41,16 +35,7 @@ describe('Basic authentication routes', () => { }, } as unknown) as RequestHandlerContext; - defineBasicRoutes({ - router, - clusterClient: elasticsearchServiceMock.createClusterClient(), - basePath: httpServiceMock.createBasePath(), - logger: loggingServiceMock.create().get(), - config: { authc: { providers: ['saml'] } } as ConfigType, - authc, - authz: authorizationMock.create(), - csp: httpServiceMock.createSetupContract().csp, - }); + defineBasicRoutes(routeParamsMock); }); describe('login', () => { diff --git a/x-pack/plugins/security/server/routes/authentication/common.test.ts b/x-pack/plugins/security/server/routes/authentication/common.test.ts index 4666b5abad756..b611ffffee935 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.test.ts @@ -14,26 +14,20 @@ import { } from '../../../../../../src/core/server'; import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; import { Authentication, DeauthenticationResult } from '../../authentication'; -import { ConfigType } from '../../config'; import { defineCommonRoutes } from './common'; -import { - elasticsearchServiceMock, - httpServerMock, - httpServiceMock, - loggingServiceMock, -} from '../../../../../../src/core/server/mocks'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { authenticationMock } from '../../authentication/index.mock'; -import { authorizationMock } from '../../authorization/index.mock'; +import { routeDefinitionParamsMock } from '../index.mock'; describe('Common authentication routes', () => { let router: jest.Mocked; let authc: jest.Mocked; let mockContext: RequestHandlerContext; beforeEach(() => { - router = httpServiceMock.createRouter(); - authc = authenticationMock.create(); + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + authc = routeParamsMock.authc; mockContext = ({ licensing: { @@ -41,16 +35,7 @@ describe('Common authentication routes', () => { }, } as unknown) as RequestHandlerContext; - defineCommonRoutes({ - router, - clusterClient: elasticsearchServiceMock.createClusterClient(), - basePath: httpServiceMock.createBasePath(), - logger: loggingServiceMock.create().get(), - config: { authc: { providers: ['saml'] } } as ConfigType, - authc, - authz: authorizationMock.create(), - csp: httpServiceMock.createSetupContract().csp, - }); + defineCommonRoutes(routeParamsMock); }); describe('logout', () => { diff --git a/x-pack/plugins/security/server/routes/authentication/index.test.ts b/x-pack/plugins/security/server/routes/authentication/index.test.ts index 5450dfafa5e49..bb7c7fb9ceb99 100644 --- a/x-pack/plugins/security/server/routes/authentication/index.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/index.test.ts @@ -5,30 +5,15 @@ */ import { defineAuthenticationRoutes } from '.'; -import { ConfigType } from '../../config'; -import { - elasticsearchServiceMock, - httpServiceMock, - loggingServiceMock, -} from '../../../../../../src/core/server/mocks'; -import { authenticationMock } from '../../authentication/index.mock'; -import { authorizationMock } from '../../authorization/index.mock'; +import { routeDefinitionParamsMock } from '../index.mock'; describe('Authentication routes', () => { it('does not register any SAML related routes if SAML auth provider is not enabled', () => { - const router = httpServiceMock.createRouter(); + const routeParamsMock = routeDefinitionParamsMock.create(); + const router = routeParamsMock.router; - defineAuthenticationRoutes({ - router, - clusterClient: elasticsearchServiceMock.createClusterClient(), - basePath: httpServiceMock.createBasePath(), - logger: loggingServiceMock.create().get(), - config: { authc: { providers: ['basic'] } } as ConfigType, - authc: authenticationMock.create(), - authz: authorizationMock.create(), - csp: httpServiceMock.createSetupContract().csp, - }); + defineAuthenticationRoutes(routeParamsMock); const samlRoutePathPredicate = ([{ path }]: [{ path: string }, any]) => path.startsWith('/api/security/saml/'); diff --git a/x-pack/plugins/security/server/routes/authentication/saml.test.ts b/x-pack/plugins/security/server/routes/authentication/saml.test.ts index b6447273c2559..b4434715a72ba 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.test.ts @@ -7,36 +7,21 @@ import { Type } from '@kbn/config-schema'; import { Authentication, AuthenticationResult, SAMLLoginStep } from '../../authentication'; import { defineSAMLRoutes } from './saml'; -import { ConfigType } from '../../config'; import { IRouter, RequestHandler, RouteConfig } from '../../../../../../src/core/server'; -import { - elasticsearchServiceMock, - httpServerMock, - httpServiceMock, - loggingServiceMock, -} from '../../../../../../src/core/server/mocks'; -import { authenticationMock } from '../../authentication/index.mock'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { authorizationMock } from '../../authorization/index.mock'; +import { routeDefinitionParamsMock } from '../index.mock'; describe('SAML authentication routes', () => { let router: jest.Mocked; let authc: jest.Mocked; beforeEach(() => { - router = httpServiceMock.createRouter(); - authc = authenticationMock.create(); - - defineSAMLRoutes({ - router, - clusterClient: elasticsearchServiceMock.createClusterClient(), - basePath: httpServiceMock.createBasePath(), - logger: loggingServiceMock.create().get(), - config: { authc: { providers: ['saml'] } } as ConfigType, - authc, - authz: authorizationMock.create(), - csp: httpServiceMock.createSetupContract().csp, - }); + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + authc = routeParamsMock.authc; + + defineSAMLRoutes(routeParamsMock); }); describe('Assertion consumer service endpoint', () => { diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 8a32e6b00bdf4..0821ed8b96af9 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -12,6 +12,7 @@ import { import { authenticationMock } from '../authentication/index.mock'; import { authorizationMock } from '../authorization/index.mock'; import { ConfigSchema } from '../config'; +import { licenseMock } from '../../common/licensing/index.mock'; export const routeDefinitionParamsMock = { create: () => ({ @@ -23,5 +24,6 @@ export const routeDefinitionParamsMock = { config: { ...ConfigSchema.validate({}), encryptionKey: 'some-enc-key' }, authc: authenticationMock.create(), authz: authorizationMock.create(), + license: licenseMock.create(), }), }; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 01df67cacb800..a372fcf092707 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -5,6 +5,7 @@ */ import { CoreSetup, IClusterClient, IRouter, Logger } from '../../../../../src/core/server'; +import { SecurityLicense } from '../../common/licensing'; import { Authentication } from '../authentication'; import { Authorization } from '../authorization'; import { ConfigType } from '../config'; @@ -15,6 +16,7 @@ import { defineApiKeysRoutes } from './api_keys'; import { defineIndicesRoutes } from './indices'; import { defineUsersRoutes } from './users'; import { defineRoleMappingRoutes } from './role_mapping'; +import { defineViewRoutes } from './views'; /** * Describes parameters used to define HTTP routes. @@ -28,6 +30,7 @@ export interface RouteDefinitionParams { config: ConfigType; authc: Authentication; authz: Authorization; + license: SecurityLicense; } export function defineRoutes(params: RouteDefinitionParams) { @@ -37,4 +40,5 @@ export function defineRoutes(params: RouteDefinitionParams) { defineIndicesRoutes(params); defineUsersRoutes(params); defineRoleMappingRoutes(params); + defineViewRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/users/change_password.test.ts b/x-pack/plugins/security/server/routes/users/change_password.test.ts index 34509edc2e9d2..b40a4e406205c 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.test.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.test.ts @@ -18,18 +18,11 @@ import { } from '../../../../../../src/core/server'; import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; import { Authentication, AuthenticationResult } from '../../authentication'; -import { ConfigType } from '../../config'; import { defineChangeUserPasswordRoutes } from './change_password'; -import { - elasticsearchServiceMock, - loggingServiceMock, - httpServiceMock, - httpServerMock, -} from '../../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { authorizationMock } from '../../authorization/index.mock'; -import { authenticationMock } from '../../authentication/index.mock'; +import { routeDefinitionParamsMock } from '../index.mock'; describe('Change password', () => { let router: jest.Mocked; @@ -51,8 +44,9 @@ describe('Change password', () => { } beforeEach(() => { - router = httpServiceMock.createRouter(); - authc = authenticationMock.create(); + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + authc = routeParamsMock.authc; authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser({ username: 'user' })); authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); @@ -64,7 +58,7 @@ describe('Change password', () => { }); mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockClusterClient = routeParamsMock.clusterClient; mockClusterClient.asScoped.mockReturnValue(mockScopedClusterClient); mockContext = ({ @@ -73,16 +67,7 @@ describe('Change password', () => { }, } as unknown) as RequestHandlerContext; - defineChangeUserPasswordRoutes({ - router, - clusterClient: mockClusterClient, - basePath: httpServiceMock.createBasePath(), - logger: loggingServiceMock.create().get(), - config: { authc: { providers: ['saml'] } } as ConfigType, - authc, - authz: authorizationMock.create(), - csp: httpServiceMock.createSetupContract().csp, - }); + defineChangeUserPasswordRoutes(routeParamsMock); const [changePasswordRouteConfig, changePasswordRouteHandler] = router.post.mock.calls[0]; routeConfig = changePasswordRouteConfig; diff --git a/x-pack/plugins/security/server/routes/views/account_management.ts b/x-pack/plugins/security/server/routes/views/account_management.ts new file mode 100644 index 0000000000000..3c84483d8f494 --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/account_management.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RouteDefinitionParams } from '..'; + +/** + * Defines routes required for the Account Management view. + */ +export function defineAccountManagementRoutes({ router, csp }: RouteDefinitionParams) { + router.get({ path: '/security/account', validate: false }, async (context, request, response) => { + return response.ok({ + body: await context.core.rendering.render({ includeUserSettings: true }), + headers: { 'content-security-policy': csp.header }, + }); + }); +} diff --git a/x-pack/plugins/security/server/routes/views/index.ts b/x-pack/plugins/security/server/routes/views/index.ts new file mode 100644 index 0000000000000..9d65e460c7b7b --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { defineAccountManagementRoutes } from './account_management'; +import { defineLoggedOutRoutes } from './logged_out'; +import { defineLoginRoutes } from './login'; +import { defineLogoutRoutes } from './logout'; +import { defineOverwrittenSessionRoutes } from './overwritten_session'; +import { RouteDefinitionParams } from '..'; + +export function defineViewRoutes(params: RouteDefinitionParams) { + if ( + params.config.authc.providers.includes('basic') || + params.config.authc.providers.includes('token') + ) { + defineLoginRoutes(params); + } + + defineAccountManagementRoutes(params); + defineLoggedOutRoutes(params); + defineLogoutRoutes(params); + defineOverwrittenSessionRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/views/logged_out.ts b/x-pack/plugins/security/server/routes/views/logged_out.ts index 0dc6caaca04c6..bd216ebcc206a 100644 --- a/x-pack/plugins/security/server/routes/views/logged_out.ts +++ b/x-pack/plugins/security/server/routes/views/logged_out.ts @@ -4,30 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ -export function initLoggedOutView( - { - __legacyCompat: { - config: { cookieName }, - }, - }, - server -) { - const config = server.config(); - const loggedOut = server.getHiddenUiAppById('logged_out'); +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ - server.route({ - method: 'GET', - path: '/logged_out', - handler(request, h) { - const isUserAlreadyLoggedIn = !!request.state[cookieName]; +import { RouteDefinitionParams } from '..'; + +/** + * Defines routes required for the Logged Out view. + */ +export function defineLoggedOutRoutes({ + router, + logger, + authc, + csp, + basePath, +}: RouteDefinitionParams) { + router.get( + { + path: '/logged_out', + validate: false, + options: { authRequired: false }, + }, + async (context, request, response) => { + // Authentication flow isn't triggered automatically for this route, so we should explicitly + // check whether user has an active session already. + const isUserAlreadyLoggedIn = (await authc.getSessionInfo(request)) != null; if (isUserAlreadyLoggedIn) { - const basePath = config.get('server.basePath'); - return h.redirect(`${basePath}/`); + logger.debug('User is already authenticated, redirecting...'); + return response.redirected({ + headers: { location: `${basePath.serverBasePath}/` }, + }); } - return h.renderAppWithDefaultConfig(loggedOut); - }, - config: { - auth: false, - }, - }); + + return response.ok({ + body: await context.core.rendering.render({ includeUserSettings: false }), + headers: { 'content-security-policy': csp.header }, + }); + } + ); } diff --git a/x-pack/plugins/security/server/routes/views/login.ts b/x-pack/plugins/security/server/routes/views/login.ts index 29468db161d9b..28df839fbde37 100644 --- a/x-pack/plugins/security/server/routes/views/login.ts +++ b/x-pack/plugins/security/server/routes/views/login.ts @@ -4,47 +4,61 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get } from 'lodash'; +import { schema } from '@kbn/config-schema'; +import { parseNext } from '../../../common/parse_next'; +import { RouteDefinitionParams } from '..'; -import { parseNext } from '../../lib/parse_next'; - -export function initLoginView( - { - __legacyCompat: { - config: { cookieName }, - license, +/** + * Defines routes required for the Login view. + */ +export function defineLoginRoutes({ + router, + logger, + authc, + csp, + basePath, + license, +}: RouteDefinitionParams) { + router.get( + { + path: '/login', + validate: { + query: schema.object( + { + next: schema.maybe(schema.string()), + msg: schema.maybe(schema.string()), + }, + { allowUnknowns: true } + ), + }, + options: { authRequired: false }, }, - }, - server -) { - const config = server.config(); - const login = server.getHiddenUiAppById('login'); + async (context, request, response) => { + // Default to true if license isn't available or it can't be resolved for some reason. + const shouldShowLogin = license.isEnabled() ? license.getFeatures().showLogin : true; - function shouldShowLogin() { - if (license.isEnabled()) { - return Boolean(license.getFeatures().showLogin); - } + // Authentication flow isn't triggered automatically for this route, so we should explicitly + // check whether user has an active session already. + const isUserAlreadyLoggedIn = (await authc.getSessionInfo(request)) != null; + if (isUserAlreadyLoggedIn || !shouldShowLogin) { + logger.debug('User is already authenticated, redirecting...'); + return response.redirected({ + headers: { location: parseNext(request.url?.href ?? '', basePath.serverBasePath) }, + }); + } - // default to true if xpack info isn't available or - // it can't be resolved for some reason - return true; - } + return response.ok({ + body: await context.core.rendering.render({ includeUserSettings: false }), + headers: { 'content-security-policy': csp.header }, + }); + } + ); - server.route({ - method: 'GET', - path: '/login', - handler(request, h) { - const isUserAlreadyLoggedIn = !!request.state[cookieName]; - if (isUserAlreadyLoggedIn || !shouldShowLogin()) { - const basePath = config.get('server.basePath'); - const url = get(request, 'raw.req.url'); - const next = parseNext(url, basePath); - return h.redirect(next); - } - return h.renderAppWithDefaultConfig(login); - }, - config: { - auth: false, - }, - }); + router.get( + { path: '/internal/security/login_state', validate: false, options: { authRequired: false } }, + async (context, request, response) => { + const { showLogin, allowLogin, layout = 'form' } = license.getFeatures(); + return response.ok({ body: { showLogin, allowLogin, layout } }); + } + ); } diff --git a/x-pack/plugins/security/server/routes/views/logout.ts b/x-pack/plugins/security/server/routes/views/logout.ts index 54607ee89faab..8fa8e689a1c38 100644 --- a/x-pack/plugins/security/server/routes/views/logout.ts +++ b/x-pack/plugins/security/server/routes/views/logout.ts @@ -4,17 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -export function initLogoutView(server) { - const logout = server.getHiddenUiAppById('logout'); +import { RouteDefinitionParams } from '..'; - server.route({ - method: 'GET', - path: '/logout', - handler(request, h) { - return h.renderAppWithDefaultConfig(logout); - }, - config: { - auth: false, +/** + * Defines routes required for the Logout out view. + */ +export function defineLogoutRoutes({ router, csp }: RouteDefinitionParams) { + router.get( + { + path: '/logout', + validate: false, + options: { authRequired: false }, }, - }); + async (context, request, response) => { + return response.ok({ + body: await context.core.rendering.render({ includeUserSettings: false }), + headers: { 'content-security-policy': csp.header }, + }); + } + ); } diff --git a/x-pack/plugins/security/server/routes/views/overwritten_session.ts b/x-pack/plugins/security/server/routes/views/overwritten_session.ts index ea99a9aeb100c..9bfa7f95ec6a3 100644 --- a/x-pack/plugins/security/server/routes/views/overwritten_session.ts +++ b/x-pack/plugins/security/server/routes/views/overwritten_session.ts @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Request, ResponseToolkit } from 'hapi'; -import { Legacy } from 'kibana'; +import { RouteDefinitionParams } from '..'; -export function initOverwrittenSessionView(server: Legacy.Server) { - server.route({ - method: 'GET', - path: '/overwritten_session', - handler(request: Request, h: ResponseToolkit) { - return h.renderAppWithDefaultConfig(server.getHiddenUiAppById('overwritten_session')); - }, - }); +/** + * Defines routes required for the Overwritten Session view. + */ +export function defineOverwrittenSessionRoutes({ router, csp }: RouteDefinitionParams) { + router.get( + { path: '/overwritten_session', validate: false }, + async (context, request, response) => { + return response.ok({ + body: await context.core.rendering.render({ includeUserSettings: true }), + headers: { 'content-security-policy': csp.header }, + }); + } + ); }