diff --git a/test/functional/config.js b/test/functional/config.js index 155e844578c54..e84b7e0a98a68 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -92,10 +92,6 @@ export default async function({ readConfigFile }) { pathname: '/app/kibana', hash: '/dev_tools/console', }, - account: { - pathname: '/app/kibana', - hash: '/account', - }, home: { pathname: '/app/kibana', hash: '/home', 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.js b/x-pack/legacy/plugins/security/index.js deleted file mode 100644 index 18b815fb429cb..0000000000000 --- a/x-pack/legacy/plugins/security/index.js +++ /dev/null @@ -1,156 +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 { 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 { AuditLogger } from '../../server/lib/audit_logger'; -import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; -import { KibanaRequest } from '../../../../src/core/server'; - -export const security = kibana => - 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' - ); - 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(); - }, - - 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.'); - } - - return { - secureCookies: securityPlugin.__legacyCompat.config.secureCookies, - session: { - tenant: server.newPlatform.setup.core.http.basePath.serverBasePath, - }, - 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.'); - } - - 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(); - } - }); - }, - - async init(server) { - const securityPlugin = server.newPlatform.setup.plugins.security; - if (!securityPlugin) { - throw new Error('New Platform XPack Security plugin is not available.'); - } - - const config = server.config(); - const xpackInfo = server.plugins.xpack_main.info; - securityPlugin.__legacyCompat.registerLegacyAPI({ - auditLogger: new AuditLogger(server, 'security', config, xpackInfo), - }); - - // Legacy xPack Info endpoint returns whatever we return in a callback for `registerLicenseCheckResultsGenerator` - // and the result is consumed by the legacy plugins all over the place, so we should keep it here for now. We assume - // that when legacy callback is called license has been already propagated to the new platform security plugin and - // features are up to date. - xpackInfo - .feature(this.id) - .registerLicenseCheckResultsGenerator(() => - securityPlugin.__legacyCompat.license.getFeatures() - ); - - 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, - }, - }; - }); - }, - }); diff --git a/x-pack/legacy/plugins/security/index.ts b/x-pack/legacy/plugins/security/index.ts new file mode 100644 index 0000000000000..deebbccf5aa49 --- /dev/null +++ b/x-pack/legacy/plugins/security/index.ts @@ -0,0 +1,93 @@ +/* + * 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 { Root } from 'joi'; +import { resolve } from 'path'; +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 { AuthenticatedUser, SecurityPluginSetup } from '../../../plugins/security/server'; + +/** + * 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'], + + // 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), + audit: Joi.object({ enabled: Joi.boolean().default(false) }).default(), + }) + .unknown() + .default(); + }, + + uiExports: { + hacks: ['plugins/security/hacks/legacy'], + injectDefaultVars: (server: Server) => { + return { + secureCookies: getSecurityPluginSetup(server).__legacyCompat.config.secureCookies, + enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'), + }; + }, + }, + + 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 getSecurityPluginSetup(server).__legacyCompat.registerPrivilegesWithCluster(); + } + }); + }, + + async init(server: Server) { + const securityPlugin = getSecurityPluginSetup(server); + + const xpackInfo = server.plugins.xpack_main.info; + securityPlugin.__legacyCompat.registerLegacyAPI({ + auditLogger: new AuditLogger(server, 'security', server.config(), xpackInfo), + }); + + // Legacy xPack Info endpoint returns whatever we return in a callback for `registerLicenseCheckResultsGenerator` + // and the result is consumed by the legacy plugins all over the place, so we should keep it here for now. We assume + // that when legacy callback is called license has been already propagated to the new platform security plugin and + // features are up to date. + xpackInfo + .feature(this.id) + .registerLicenseCheckResultsGenerator(() => + securityPlugin.__legacyCompat.license.getFeatures() + ); + + server.expose({ + 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..2c683fe4ecf80 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/hacks/legacy.ts @@ -0,0 +1,64 @@ +/* + * 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_account'), + }); + + 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) { + 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/_index.scss b/x-pack/legacy/plugins/security/public/views/_index.scss deleted file mode 100644 index 6c2a091adf536..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/_index.scss +++ /dev/null @@ -1,2 +0,0 @@ -// Login styles -@import './login/index'; diff --git a/x-pack/legacy/plugins/security/public/views/account/account.js b/x-pack/legacy/plugins/security/public/views/account/account.js deleted file mode 100644 index 13abc44e08f96..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/account/account.js +++ /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/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/logged_out/logged_out.tsx b/x-pack/legacy/plugins/security/public/views/logged_out/logged_out.tsx deleted file mode 100644 index dbeb68875c1a9..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/logged_out/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/login/_index.scss b/x-pack/legacy/plugins/security/public/views/login/_index.scss deleted file mode 100644 index 9083c8dc3b775..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/login/_index.scss +++ /dev/null @@ -1,8 +0,0 @@ -// Prefix all styles with "login" to avoid conflicts. -// Examples -// loginChart -// loginChart__legend -// loginChart__legend--small -// loginChart__legend-isLoading - -@import './components/index'; 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/login/components/basic_login_form/basic_login_form.test.tsx b/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.test.tsx deleted file mode 100644 index 3a970d582bdc8..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.test.tsx +++ /dev/null @@ -1,109 +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 { EuiButton, EuiCallOut } from '@elastic/eui'; -import React from 'react'; -import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { LoginState } from '../../login_state'; -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; -}; - -describe('BasicLoginForm', () => { - 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(); - - const wrapper = shallowWithIntl( - - ); - - expect(wrapper.find(EuiCallOut).props().title).toEqual('Hey this is an info message'); - }); - - it('renders an invalid credentials message', async () => { - const mockHttp = createMockHttp({ simulateError: true }); - const mockWindow = {}; - const loginState = createLoginState(); - - 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(); - - expect(wrapper.find(EuiCallOut).props().title).toEqual( - `Invalid username or password. Please try again.` - ); - }); -}); diff --git a/x-pack/legacy/plugins/security/public/views/login/components/login_page/__snapshots__/login_page.test.tsx.snap b/x-pack/legacy/plugins/security/public/views/login/components/login_page/__snapshots__/login_page.test.tsx.snap deleted file mode 100644 index 17ba81988414a..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/login/components/login_page/__snapshots__/login_page.test.tsx.snap +++ /dev/null @@ -1,485 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LoginPage disabled form states renders as expected when a connection to ES is not available 1`] = ` -
-
-
- - - - - -

- -

-
- -

- -

-
- -
-
-
- - - - } - title={ - - } - /> - - -
-
-`; - -exports[`LoginPage disabled form states renders as expected when an unknown loginState layout is provided 1`] = ` -
-
-
- - - - - -

- -

-
- -

- -

-
- -
-
-
- - - - } - title={ - - } - /> - - -
-
-`; - -exports[`LoginPage disabled form states renders as expected when loginAssistanceMessage is set 1`] = ` -
-
-
- - - - - -

- -

-
- -

- -

-
- -
-
-
- - - - - -
-
-`; - -exports[`LoginPage disabled form states renders as expected when secure cookies are required but not present 1`] = ` -
-
-
- - - - - -

- -

-
- -

- -

-
- -
-
-
- - - - } - title={ - - } - /> - - -
-
-`; - -exports[`LoginPage disabled form states renders as expected when xpack is not available 1`] = ` -
-
-
- - - - - -

- -

-
- -

- -

-
- -
-
-
- - - - } - title={ - - } - /> - - -
-
-`; - -exports[`LoginPage enabled form state renders as expected 1`] = ` -
-
-
- - - - - -

- -

-
- -

- -

-
- -
-
-
- - - - - -
-
-`; diff --git a/x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.test.tsx b/x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.test.tsx deleted file mode 100644 index a0318d50a45e5..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.test.tsx +++ /dev/null @@ -1,133 +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 { shallow } from 'enzyme'; -import React from 'react'; -import { LoginLayout, 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, - }; - }), - }; -}; - -const createLoginState = (options?: Partial) => { - return { - allowLogin: true, - layout: 'form', - ...options, - } as LoginState; -}; - -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 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 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 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 loginAssistanceMessage is set', () => { - const props = { - http: createMockHttp(), - window: {}, - next: '', - loginState: createLoginState(), - isSecureConnection: false, - requiresSecureConnection: false, - loginAssistanceMessage: 'This is an *important* message', - }; - - expect(shallow()).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(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/public/views/login/login.tsx b/x-pack/legacy/plugins/security/public/views/login/login.tsx deleted file mode 100644 index 0b89ac553c9a8..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/login/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/logout/index.js b/x-pack/legacy/plugins/security/public/views/logout/index.js deleted file mode 100644 index 56588d4f746f1..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/logout/index.js +++ /dev/null @@ -1,7 +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 './logout'; diff --git a/x-pack/legacy/plugins/security/public/views/logout/logout.js b/x-pack/legacy/plugins/security/public/views/logout/logout.js deleted file mode 100644 index 97010ec81bbf5..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/logout/logout.js +++ /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/index.js b/x-pack/legacy/plugins/security/public/views/overwritten_session/index.js deleted file mode 100644 index f3ba8a6b9d7c5..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/overwritten_session/index.js +++ /dev/null @@ -1,7 +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 './overwritten_session'; diff --git a/x-pack/legacy/plugins/security/public/views/overwritten_session/overwritten_session.tsx b/x-pack/legacy/plugins/security/public/views/overwritten_session/overwritten_session.tsx deleted file mode 100644 index 4c79c499cc0e6..0000000000000 --- a/x-pack/legacy/plugins/security/public/views/overwritten_session/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/legacy/plugins/security/server/routes/views/logged_out.js b/x-pack/legacy/plugins/security/server/routes/views/logged_out.js deleted file mode 100644 index 0dc6caaca04c6..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/views/logged_out.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. - */ - -export function initLoggedOutView( - { - __legacyCompat: { - config: { cookieName }, - }, - }, - server -) { - const config = server.config(); - const loggedOut = server.getHiddenUiAppById('logged_out'); - - server.route({ - method: 'GET', - path: '/logged_out', - handler(request, h) { - const isUserAlreadyLoggedIn = !!request.state[cookieName]; - if (isUserAlreadyLoggedIn) { - const basePath = config.get('server.basePath'); - return h.redirect(`${basePath}/`); - } - return h.renderAppWithDefaultConfig(loggedOut); - }, - config: { - auth: false, - }, - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/views/login.js b/x-pack/legacy/plugins/security/server/routes/views/login.js deleted file mode 100644 index 29468db161d9b..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/views/login.js +++ /dev/null @@ -1,50 +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 { get } from 'lodash'; - -import { parseNext } from '../../lib/parse_next'; - -export function initLoginView( - { - __legacyCompat: { - config: { cookieName }, - license, - }, - }, - server -) { - const config = server.config(); - const login = server.getHiddenUiAppById('login'); - - function shouldShowLogin() { - if (license.isEnabled()) { - return Boolean(license.getFeatures().showLogin); - } - - // default to true if xpack info isn't available or - // it can't be resolved for some reason - return true; - } - - 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, - }, - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/views/logout.js b/x-pack/legacy/plugins/security/server/routes/views/logout.js deleted file mode 100644 index 54607ee89faab..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/views/logout.js +++ /dev/null @@ -1,20 +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. - */ - -export function initLogoutView(server) { - const logout = server.getHiddenUiAppById('logout'); - - server.route({ - method: 'GET', - path: '/logout', - handler(request, h) { - return h.renderAppWithDefaultConfig(logout); - }, - config: { - auth: false, - }, - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/views/overwritten_session.ts b/x-pack/legacy/plugins/security/server/routes/views/overwritten_session.ts deleted file mode 100644 index ea99a9aeb100c..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/views/overwritten_session.ts +++ /dev/null @@ -1,18 +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 { Request, ResponseToolkit } from 'hapi'; -import { Legacy } from 'kibana'; - -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')); - }, - }); -} diff --git a/x-pack/legacy/plugins/xpack_main/public/services/path.js b/x-pack/legacy/plugins/xpack_main/public/services/path.js index d66cf44e69b4f..d2fe550178e61 100644 --- a/x-pack/legacy/plugins/xpack_main/public/services/path.js +++ b/x-pack/legacy/plugins/xpack_main/public/services/path.js @@ -9,6 +9,11 @@ import chrome from 'ui/chrome'; export const Path = { isUnauthenticated() { const path = chrome.removeBasePath(window.location.pathname); - return path === '/login' || path === '/logout' || path === '/logged_out' || path === '/status'; + return ( + path === '/login' || + path === '/logout' || + path === '/security/logged_out' || + path === '/status' + ); }, }; diff --git a/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/__tests__/xpack_info.js b/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/__tests__/xpack_info.js index 57a28a3e4769a..540d9f63ea6c8 100644 --- a/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/__tests__/xpack_info.js +++ b/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/__tests__/xpack_info.js @@ -58,7 +58,6 @@ describe('XPackInfo routes', () => { showLinks: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, - linksMessage: 'Message', }, }, }); @@ -79,7 +78,6 @@ describe('XPackInfo routes', () => { show_links: false, allow_role_document_level_security: false, allow_role_field_level_security: false, - links_message: 'Message', }, }, }); diff --git a/x-pack/legacy/plugins/security/README.md b/x-pack/plugins/security/README.md similarity index 100% rename from x-pack/legacy/plugins/security/README.md rename to x-pack/plugins/security/README.md 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..bef328f54de03 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,10 +51,5 @@ export interface SecurityLicenseFeatures { /** * Describes the layout of the login form if it's displayed. */ - readonly layout?: string; - - /** - * Message to show when security links are clicked throughout the kibana app. - */ - readonly linksMessage?: string; + readonly layout?: LoginLayout; } diff --git a/x-pack/plugins/security/common/licensing/license_service.test.ts b/x-pack/plugins/security/common/licensing/license_service.test.ts index df2d66a036039..40e8901970af8 100644 --- a/x-pack/plugins/security/common/licensing/license_service.test.ts +++ b/x-pack/plugins/security/common/licensing/license_service.test.ts @@ -79,7 +79,6 @@ describe('license features', function() { "allowRbac": false, "allowRoleDocumentLevelSecurity": false, "allowRoleFieldLevelSecurity": false, - "linksMessage": "Access is denied because Security is disabled in Elasticsearch.", "showLinks": false, "showLogin": false, "showRoleMappingsManagement": false, @@ -130,7 +129,6 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, - linksMessage: 'Access is denied because Security is disabled in Elasticsearch.', }); }); diff --git a/x-pack/plugins/security/common/licensing/license_service.ts b/x-pack/plugins/security/common/licensing/license_service.ts index e6d2eff49ed0d..2c2039c5e2e92 100644 --- a/x-pack/plugins/security/common/licensing/license_service.ts +++ b/x-pack/plugins/security/common/licensing/license_service.ts @@ -90,7 +90,6 @@ export class SecurityLicenseService { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, - linksMessage: 'Access is denied because Security is disabled in Elasticsearch.', }; } diff --git a/x-pack/legacy/plugins/security/public/views/login/parse_next.test.ts b/x-pack/plugins/security/common/parse_next.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/public/views/login/parse_next.test.ts rename to x-pack/plugins/security/common/parse_next.test.ts diff --git a/x-pack/legacy/plugins/security/public/views/login/parse_next.ts b/x-pack/plugins/security/common/parse_next.ts similarity index 100% rename from x-pack/legacy/plugins/security/public/views/login/parse_next.ts rename to x-pack/plugins/security/common/parse_next.ts 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..ad40c61718c73 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.ts @@ -0,0 +1,73 @@ +/* + * 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, ScopedHistory } from 'src/core/public'; +import { UserAPIClient } from '../management'; +import { accountManagementApp } from './account_management_app'; + +import { coreMock, scopedHistoryMock } 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_account', + 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(), + history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + }); + + 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, { + userAPIClient: 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..8a14a772a1eef --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.ts @@ -0,0 +1,46 @@ +/* + * 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_account', + create({ application, authc, getStartServices }: CreateDeps) { + const title = i18n.translate('xpack.security.account.breadcrumb', { + defaultMessage: 'Account Management', + }); + application.register({ + id: this.id, + title, + // 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: title }]); + + return renderAccountManagementPage(coreStart.i18n, element, { + authc, + notifications: coreStart.notifications, + userAPIClient: 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 9388c2e9b19b8..6615e8fee9412 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 { @@ -50,3 +51,18 @@ export const AccountManagementPage = ({ userAPIClient, authc, notifications }: P ); }; + +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 new file mode 100644 index 0000000000000..0a423c00f0218 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/_index.scss @@ -0,0 +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/components/_index.scss b/x-pack/plugins/security/public/authentication/components/_index.scss similarity index 100% rename from x-pack/legacy/plugins/security/public/components/_index.scss rename to x-pack/plugins/security/public/authentication/components/_index.scss diff --git a/x-pack/legacy/plugins/security/public/components/authentication_state_page/__snapshots__/authentication_state_page.test.tsx.snap b/x-pack/plugins/security/public/authentication/components/authentication_state_page/__snapshots__/authentication_state_page.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/security/public/components/authentication_state_page/__snapshots__/authentication_state_page.test.tsx.snap rename to x-pack/plugins/security/public/authentication/components/authentication_state_page/__snapshots__/authentication_state_page.test.tsx.snap diff --git a/x-pack/legacy/plugins/security/public/components/authentication_state_page/_authentication_state_page.scss b/x-pack/plugins/security/public/authentication/components/authentication_state_page/_authentication_state_page.scss similarity index 100% rename from x-pack/legacy/plugins/security/public/components/authentication_state_page/_authentication_state_page.scss rename to x-pack/plugins/security/public/authentication/components/authentication_state_page/_authentication_state_page.scss diff --git a/x-pack/legacy/plugins/security/public/components/authentication_state_page/_index.scss b/x-pack/plugins/security/public/authentication/components/authentication_state_page/_index.scss similarity index 100% rename from x-pack/legacy/plugins/security/public/components/authentication_state_page/_index.scss rename to x-pack/plugins/security/public/authentication/components/authentication_state_page/_index.scss diff --git a/x-pack/legacy/plugins/security/public/components/authentication_state_page/authentication_state_page.test.tsx b/x-pack/plugins/security/public/authentication/components/authentication_state_page/authentication_state_page.test.tsx similarity index 100% rename from x-pack/legacy/plugins/security/public/components/authentication_state_page/authentication_state_page.test.tsx rename to x-pack/plugins/security/public/authentication/components/authentication_state_page/authentication_state_page.test.tsx diff --git a/x-pack/legacy/plugins/security/public/components/authentication_state_page/authentication_state_page.tsx b/x-pack/plugins/security/public/authentication/components/authentication_state_page/authentication_state_page.tsx similarity index 100% rename from x-pack/legacy/plugins/security/public/components/authentication_state_page/authentication_state_page.tsx rename to x-pack/plugins/security/public/authentication/components/authentication_state_page/authentication_state_page.tsx diff --git a/x-pack/legacy/plugins/security/public/components/authentication_state_page/index.tsx b/x-pack/plugins/security/public/authentication/components/authentication_state_page/index.tsx similarity index 100% rename from x-pack/legacy/plugins/security/public/components/authentication_state_page/index.tsx rename to x-pack/plugins/security/public/authentication/components/authentication_state_page/index.tsx diff --git a/x-pack/plugins/security/public/authentication/components/index.ts b/x-pack/plugins/security/public/authentication/components/index.ts new file mode 100644 index 0000000000000..b0f2324d6fe52 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/components/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { AuthenticationStatePage } from './authentication_state_page'; diff --git a/x-pack/legacy/plugins/security/public/views/login/components/index.ts b/x-pack/plugins/security/public/authentication/logged_out/index.ts similarity index 83% rename from x-pack/legacy/plugins/security/public/views/login/components/index.ts rename to x-pack/plugins/security/public/authentication/logged_out/index.ts index e3ce25c0f46fe..7f65c12c22a6c 100644 --- a/x-pack/legacy/plugins/security/public/views/login/components/index.ts +++ 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. */ -export { LoginPage } from './login_page'; +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..c8303ecc940d6 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts @@ -0,0 +1,58 @@ +/* + * 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, ScopedHistory } from 'src/core/public'; +import { loggedOutApp } from './logged_out_app'; + +import { coreMock, scopedHistoryMock } 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('/security/logged_out'); + + expect(coreSetupMock.application.register).toHaveBeenCalledTimes(1); + + const [[appRegistration]] = coreSetupMock.application.register.mock.calls; + expect(appRegistration).toEqual({ + id: 'security_logged_out', + chromeless: true, + appRoute: '/security/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(), + history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + }); + + 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..b7f2615318791 --- /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: 'security_logged_out', + create({ application, http, getStartServices }: CreateDeps) { + http.anonymousPaths.register('/security/logged_out'); + application.register({ + id: this.id, + title: i18n.translate('xpack.security.loggedOutAppTitle', { defaultMessage: 'Logged out' }), + chromeless: true, + appRoute: '/security/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 new file mode 100644 index 0000000000000..c1b8202e2f3f3 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap @@ -0,0 +1,188 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LoginPage disabled form states renders as expected when a connection to ES is not available 1`] = ` + + } + title={ + + } +/> +`; + +exports[`LoginPage disabled form states renders as expected when an unknown loginState layout is provided 1`] = ` + + } + title={ + + } +/> +`; + +exports[`LoginPage disabled form states renders as expected when secure connection is required but not present 1`] = ` + + } + title={ + + } +/> +`; + +exports[`LoginPage disabled form states renders as expected when xpack is not available 1`] = ` + + } + title={ + + } +/> +`; + +exports[`LoginPage enabled form state renders as expected 1`] = ` + +`; + +exports[`LoginPage enabled form state renders as expected when info message is set 1`] = ` + +`; + +exports[`LoginPage enabled form state renders as expected when loginAssistanceMessage is set 1`] = ` + +`; + +exports[`LoginPage page renders as expected 1`] = ` +
+
+
+ + + + + +

+ +

+
+ +

+ +

+
+ +
+
+
+ + + + + +
+
+`; diff --git a/x-pack/legacy/plugins/security/public/views/login/components/login_page/_index.scss b/x-pack/plugins/security/public/authentication/login/_index.scss similarity index 100% rename from x-pack/legacy/plugins/security/public/views/login/components/login_page/_index.scss rename to x-pack/plugins/security/public/authentication/login/_index.scss diff --git a/x-pack/legacy/plugins/security/public/views/login/components/login_page/_login_page.scss b/x-pack/plugins/security/public/authentication/login/_login_page.scss similarity index 100% rename from x-pack/legacy/plugins/security/public/views/login/components/login_page/_login_page.scss rename to x-pack/plugins/security/public/authentication/login/_login_page.scss diff --git a/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap rename to x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap 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 new file mode 100644 index 0000000000000..e62fd7191dfae --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx @@ -0,0 +1,111 @@ +/* + * 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 { 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'; + +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', () => { + expect( + shallowWithIntl( + + ) + ).toMatchSnapshot(); + }); + + it('renders an info message when provided.', () => { + const wrapper = shallowWithIntl( + + ); + + expect(wrapper.find(EuiCallOut).props().title).toEqual('Hey this is an info message'); + }); + + it('renders an invalid credentials message', async () => { + 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'); + + 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/legacy/plugins/security/public/views/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 similarity index 76% rename from x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.tsx rename to x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx index d5658cc297c26..7302ee9bf9851 100644 --- a/x-pack/legacy/plugins/security/public/views/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/legacy/plugins/security/public/views/login/components/basic_login_form/index.ts b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/index.ts similarity index 100% rename from x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/index.ts rename to x-pack/plugins/security/public/authentication/login/components/basic_login_form/index.ts diff --git a/x-pack/legacy/plugins/security/public/views/login/components/disabled_login_form/__snapshots__/disabled_login_form.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/components/disabled_login_form/__snapshots__/disabled_login_form.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/security/public/views/login/components/disabled_login_form/__snapshots__/disabled_login_form.test.tsx.snap rename to x-pack/plugins/security/public/authentication/login/components/disabled_login_form/__snapshots__/disabled_login_form.test.tsx.snap diff --git a/x-pack/legacy/plugins/security/public/views/login/components/disabled_login_form/disabled_login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/disabled_login_form/disabled_login_form.test.tsx similarity index 100% rename from x-pack/legacy/plugins/security/public/views/login/components/disabled_login_form/disabled_login_form.test.tsx rename to x-pack/plugins/security/public/authentication/login/components/disabled_login_form/disabled_login_form.test.tsx diff --git a/x-pack/legacy/plugins/security/public/views/login/components/disabled_login_form/disabled_login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/disabled_login_form/disabled_login_form.tsx similarity index 100% rename from x-pack/legacy/plugins/security/public/views/login/components/disabled_login_form/disabled_login_form.tsx rename to x-pack/plugins/security/public/authentication/login/components/disabled_login_form/disabled_login_form.tsx diff --git a/x-pack/legacy/plugins/security/public/views/login/components/disabled_login_form/index.ts b/x-pack/plugins/security/public/authentication/login/components/disabled_login_form/index.ts similarity index 100% rename from x-pack/legacy/plugins/security/public/views/login/components/disabled_login_form/index.ts rename to x-pack/plugins/security/public/authentication/login/components/disabled_login_form/index.ts diff --git a/x-pack/plugins/security/public/authentication/login/components/index.ts b/x-pack/plugins/security/public/authentication/login/components/index.ts new file mode 100644 index 0000000000000..5f267f7c4caa2 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { BasicLoginForm } from './basic_login_form'; +export { DisabledLoginForm } from './disabled_login_form'; diff --git a/x-pack/legacy/plugins/security/public/views/login/index.ts b/x-pack/plugins/security/public/authentication/login/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/login/index.ts index b2de507d5ee12..c965dced799eb 100644 --- a/x-pack/legacy/plugins/security/public/views/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. */ -import './login'; +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..051f08058ed8d --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts @@ -0,0 +1,70 @@ +/* + * 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, ScopedHistory } from 'src/core/public'; +import { loginApp } from './login_app'; + +import { coreMock, scopedHistoryMock } 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: 'security_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(), + history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + }); + + 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..4f4bf3903a1fa --- /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: 'security_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 new file mode 100644 index 0000000000000..294434cd08ebc --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx @@ -0,0 +1,282 @@ +/* + * 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 { 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'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { DisabledLoginForm, BasicLoginForm } from './components'; + +const createLoginState = (options?: Partial) => { + return { + allowLogin: true, + layout: 'form', + ...options, + } as LoginState; +}; + +describe('LoginPage', () => { + // mock a minimal subset of the HttpSetup + const httpMock = { + get: jest.fn(), + addLoadingCountSource: jest.fn(), + } as any; + const resetHttpMock = () => { + httpMock.get.mockReset(); + httpMock.addLoadingCountSource.mockReset(); + }; + + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { href: 'http://some-host/bar', protocol: 'http' }, + writable: true, + }); + }); + + beforeEach(() => { + resetHttpMock(); + }); + + afterAll(() => { + delete (window as any).location; + }); + + describe('page', () => { + it('renders as expected', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue(createLoginState()); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot + }); + + expect(wrapper).toMatchSnapshot(); + }); + }); + + describe('disabled form states', () => { + it('renders as expected when secure connection is required but not present', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue(createLoginState()); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(DisabledLoginForm)).toMatchSnapshot(); + }); + + it('renders as expected when a connection to ES is not available', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue(createLoginState({ layout: 'error-es-unavailable' })); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(DisabledLoginForm)).toMatchSnapshot(); + }); + + it('renders as expected when xpack is not available', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue(createLoginState({ layout: 'error-xpack-unavailable' })); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(DisabledLoginForm)).toMatchSnapshot(); + }); + + it('renders as expected when an unknown loginState layout is provided', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue( + createLoginState({ layout: 'error-asdf-asdf-unknown' as any }) + ); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(DisabledLoginForm)).toMatchSnapshot(); + }); + }); + + describe('enabled form state', () => { + it('renders as expected', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue(createLoginState()); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot + }); + + expect(wrapper.find(BasicLoginForm)).toMatchSnapshot(); + }); + + it('renders as expected when info message is set', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue(createLoginState()); + window.location.href = 'http://some-host/bar?msg=SESSION_EXPIRED'; + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot + }); + + expect(wrapper.find(BasicLoginForm)).toMatchSnapshot(); + }); + + it('renders as expected when loginAssistanceMessage is set', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue(createLoginState()); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot + }); + + expect(wrapper.find(BasicLoginForm)).toMatchSnapshot(); + }); + }); + + describe('API calls', () => { + it('GET login_state success', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue(createLoginState()); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(httpMock.addLoadingCountSource).toHaveBeenCalledTimes(1); + expect(httpMock.get).toHaveBeenCalledTimes(1); + expect(httpMock.get).toHaveBeenCalledWith('/internal/security/login_state'); + expect(coreStartMock.fatalErrors.add).not.toHaveBeenCalled(); + }); + + it('GET login_state failure', async () => { + const coreStartMock = coreMock.createStart(); + const error = Symbol(); + httpMock.get.mockRejectedValue(error); + + const wrapper = shallow( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(httpMock.addLoadingCountSource).toHaveBeenCalledTimes(1); + expect(httpMock.get).toHaveBeenCalledTimes(1); + expect(httpMock.get).toHaveBeenCalledWith('/internal/security/login_state'); + expect(coreStartMock.fatalErrors.add).toHaveBeenCalledTimes(1); + expect(coreStartMock.fatalErrors.add).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.tsx b/x-pack/plugins/security/public/authentication/login/login_page.tsx similarity index 60% rename from x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.tsx rename to x-pack/plugins/security/public/authentication/login/login_page.tsx index 8035789a30e9d..848751aa03352 100644 --- a/x-pack/legacy/plugins/security/public/views/login/components/login_page/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/legacy/plugins/security/public/views/login/login_state.ts b/x-pack/plugins/security/public/authentication/login/login_state.ts similarity index 78% rename from x-pack/legacy/plugins/security/public/views/login/login_state.ts rename to x-pack/plugins/security/public/authentication/login/login_state.ts index b1eb3d61fe5f3..6ca38296706fe 100644 --- a/x-pack/legacy/plugins/security/public/views/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/components/login_page/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/components/login_page/index.ts rename to x-pack/plugins/security/public/authentication/logout/index.ts index e3ce25c0f46fe..981811ab21eed 100644 --- a/x-pack/legacy/plugins/security/public/views/login/components/login_page/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. */ -export { LoginPage } from './login_page'; +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..c17a0c2ca27b1 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts @@ -0,0 +1,66 @@ +/* + * 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, ScopedHistory } from 'src/core/public'; +import { logoutApp } from './logout_app'; + +import { coreMock, scopedHistoryMock } 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: 'security_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(), + history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + }); + + 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..72f69ce4460c3 --- /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: 'security_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/__snapshots__/overwritten_session_page.test.tsx.snap b/x-pack/plugins/security/public/authentication/overwritten_session/__snapshots__/overwritten_session_page.test.tsx.snap new file mode 100644 index 0000000000000..2ce59ab37f514 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/overwritten_session/__snapshots__/overwritten_session_page.test.tsx.snap @@ -0,0 +1,135 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OverwrittenSessionPage renders as expected 1`] = ` + + } +> +
+
+
+ +
+ + + + + + + + + + + + + + + +

+ + You previously logged in as a different user. + +

+
+ +
+ +
+
+ +
+
+`; diff --git a/x-pack/legacy/plugins/security/public/views/logged_out/index.js b/x-pack/plugins/security/public/authentication/overwritten_session/index.ts similarity index 78% rename from x-pack/legacy/plugins/security/public/views/logged_out/index.js rename to x-pack/plugins/security/public/authentication/overwritten_session/index.ts index 3a2281bd6beee..a9552a1157a19 100644 --- a/x-pack/legacy/plugins/security/public/views/logged_out/index.js +++ 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 './logged_out'; +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..7b15d8c46f6eb --- /dev/null +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts @@ -0,0 +1,67 @@ +/* + * 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, ScopedHistory } from 'src/core/public'; +import { overwrittenSessionApp } from './overwritten_session_app'; + +import { coreMock, scopedHistoryMock } 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: 'security_overwritten_session', + title: 'Overwritten Session', + chromeless: true, + appRoute: '/security/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(), + history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + }); + + 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..1bbe388a635e2 --- /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: 'security_overwritten_session', + create({ application, authc, getStartServices }: CreateDeps) { + application.register({ + id: this.id, + title: i18n.translate('xpack.security.overwrittenSessionAppTitle', { + defaultMessage: 'Overwritten Session', + }), + chromeless: true, + appRoute: '/security/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.test.tsx b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.test.tsx new file mode 100644 index 0000000000000..7422319951a8a --- /dev/null +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.test.tsx @@ -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 React from 'react'; +import { act } from '@testing-library/react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { OverwrittenSessionPage } from './overwritten_session_page'; + +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { authenticationMock } from '../index.mock'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { AuthenticationStatePage } from '../components/authentication_state_page'; + +describe('OverwrittenSessionPage', () => { + it('renders as expected', async () => { + const basePathMock = coreMock.createStart({ basePath: '/mock-base-path' }).http.basePath; + const authenticationSetupMock = authenticationMock.createSetup(); + authenticationSetupMock.getCurrentUser.mockResolvedValue( + mockAuthenticatedUser({ username: 'mock-user' }) + ); + + const wrapper = mountWithIntl( + + ); + + // Shouldn't render anything if username isn't yet available. + expect(wrapper.isEmptyRender()).toBe(true); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(AuthenticationStatePage)).toMatchSnapshot(); + }); +}); 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/hacks/register_account_management_app.ts b/x-pack/plugins/security/public/config.ts similarity index 78% rename from x-pack/legacy/plugins/security/public/hacks/register_account_management_app.ts rename to x-pack/plugins/security/public/config.ts index 4fdc2358246b9..56bd02976c1b4 100644 --- a/x-pack/legacy/plugins/security/public/hacks/register_account_management_app.ts +++ 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 '../views/account/account'; +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..3d0ef3b2cabc7 --- /dev/null +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -0,0 +1,147 @@ +/* + * 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 BroadcastChannel from 'broadcast-channel'; +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', () => { + beforeAll(() => { + BroadcastChannel.enforceOptions({ type: 'simulate' }); + }); + afterAll(() => { + BroadcastChannel.enforceOptions(null); + }); + + 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()); + plugin.setup( + coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup, + { licensing: licensingMock.createSetup() } + ); + + 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 8e5c4f87e3647..dcd90b1738f10 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,22 +141,6 @@ export class SecurityPlugin if (management) { this.managementService.start({ management }); } - - return { - __legacyCompat: { - account_management: { - AccountManagementPage: () => ( - - - - ), - }, - }, - }; } public stop() { diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 4954e1b24216c..e2e2d12917394 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -192,7 +192,6 @@ export class Authenticator { client: this.options.clusterClient, logger: this.options.loggers.get('tokens'), }), - isProviderEnabled: this.isProviderEnabled.bind(this), }; const authProviders = this.options.config.authc.providers; diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index 51fb961482e83..955805296e2bd 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -494,7 +494,7 @@ describe('KerberosAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); await expect(provider.logout(request, tokenPair)).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index b6474a5e1d471..632a07ca2b21a 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -91,7 +91,9 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.failed(err); } - return DeauthenticationResult.redirectTo(`${this.options.basePath.serverBasePath}/logged_out`); + return DeauthenticationResult.redirectTo( + `${this.options.basePath.serverBasePath}/security/logged_out` + ); } /** diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index 51a25825bf985..6a4ba1ccb41e2 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -575,7 +575,7 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index c6b504e722adf..d52466826c2be 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -395,7 +395,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } return DeauthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/logged_out` + `${this.options.basePath.serverBasePath}/security/logged_out` ); } catch (err) { this.logger.debug(`Failed to deauthenticate user: ${err.message}`); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index efc286c6c895f..044416032a4c3 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -511,7 +511,7 @@ describe('PKIAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); await expect(provider.logout(request, state)).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index 854f92a50fa9d..252ab8cc67144 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -98,7 +98,9 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.failed(err); } - return DeauthenticationResult.redirectTo(`${this.options.basePath.serverBasePath}/logged_out`); + return DeauthenticationResult.redirectTo( + `${this.options.basePath.serverBasePath}/security/logged_out` + ); } /** diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index d97a6c0838b86..e00d3b89fb0bf 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -365,7 +365,7 @@ describe('SAMLAuthenticationProvider', () => { state ) ).resolves.toEqual( - AuthenticationResult.redirectTo('/base-path/overwritten_session', { + AuthenticationResult.redirectTo('/mock-server-basepath/security/overwritten_session', { state: { username: 'new-user', accessToken: 'new-valid-token', @@ -959,7 +959,7 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it('redirects to /logged_out if `redirect` field in SAML logout response is null.', async () => { + it('redirects to /security/logged_out if `redirect` field in SAML logout response is null.', async () => { const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; @@ -968,7 +968,9 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { username: 'user', accessToken, refreshToken }) - ).resolves.toEqual(DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out')); + ).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { @@ -976,7 +978,7 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it('redirects to /logged_out if `redirect` field in SAML logout response is not defined.', async () => { + it('redirects to /security/logged_out if `redirect` field in SAML logout response is not defined.', async () => { const request = httpServerMock.createKibanaRequest(); const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; @@ -985,7 +987,9 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { username: 'user', accessToken, refreshToken }) - ).resolves.toEqual(DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out')); + ).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { @@ -1004,7 +1008,9 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { username: 'user', accessToken, refreshToken }) - ).resolves.toEqual(DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out')); + ).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { @@ -1023,7 +1029,9 @@ describe('SAMLAuthenticationProvider', () => { accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', }) - ).resolves.toEqual(DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out')); + ).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { @@ -1031,13 +1039,13 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it('redirects to /logged_out if `redirect` field in SAML invalidate response is null.', async () => { + it('redirects to /security/logged_out if `redirect` field in SAML invalidate response is null.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); await expect(provider.logout(request)).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); @@ -1046,13 +1054,13 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it('redirects to /logged_out if `redirect` field in SAML invalidate response is not defined.', async () => { + it('redirects to /security/logged_out if `redirect` field in SAML invalidate response is not defined.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); await expect(provider.logout(request)).resolves.toEqual( - DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 1ac59d66a2235..1152ee5048699 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -231,7 +231,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } return DeauthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/logged_out` + `${this.options.basePath.serverBasePath}/security/logged_out` ); } catch (err) { this.logger.debug(`Failed to deauthenticate user: ${err.message}`); @@ -366,7 +366,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { 'Login initiated by Identity Provider is for a different user than currently authenticated.' ); return AuthenticationResult.redirectTo( - `${this.options.basePath.get(request)}/overwritten_session`, + `${this.options.basePath.serverBasePath}/security/overwritten_session`, { state: newState } ); } diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 64c695670fa19..9f7f2736766ed 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -14,6 +14,9 @@ describe('config schema', () => { it('generates proper defaults', () => { expect(ConfigSchema.validate({})).toMatchInlineSnapshot(` Object { + "audit": Object { + "enabled": false, + }, "authc": Object { "http": Object { "autoSchemesEnabled": true, @@ -27,6 +30,7 @@ describe('config schema', () => { ], }, "cookieName": "sid", + "enabled": true, "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "loginAssistanceMessage": "", "secureCookies": false, @@ -39,6 +43,9 @@ describe('config schema', () => { expect(ConfigSchema.validate({}, { dist: false })).toMatchInlineSnapshot(` Object { + "audit": Object { + "enabled": false, + }, "authc": Object { "http": Object { "autoSchemesEnabled": true, @@ -52,6 +59,7 @@ describe('config schema', () => { ], }, "cookieName": "sid", + "enabled": true, "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "loginAssistanceMessage": "", "secureCookies": false, @@ -64,6 +72,9 @@ describe('config schema', () => { expect(ConfigSchema.validate({}, { dist: true })).toMatchInlineSnapshot(` Object { + "audit": Object { + "enabled": false, + }, "authc": Object { "http": Object { "autoSchemesEnabled": true, @@ -77,6 +88,7 @@ describe('config schema', () => { ], }, "cookieName": "sid", + "enabled": true, "loginAssistanceMessage": "", "secureCookies": false, "session": Object { diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 8663a6e61c203..2345249e94bc8 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -24,41 +24,41 @@ 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()), + http: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + autoSchemesEnabled: schema.boolean({ defaultValue: true }), + schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey'] }), }), - 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' }), - }) - ), - http: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - autoSchemesEnabled: schema.boolean({ defaultValue: true }), - schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey'] }), - }), - }), - }, - // 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 e1167af0be7f0..0b17f0554fac8 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -44,6 +44,9 @@ export const config: PluginConfigDescriptor> = { return settings; }, ], + 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 6f5c79e873e86..a1ef352056d6a 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -50,8 +50,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..13300ee55eba0 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,10 @@ 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. + // This is only currently required because we use legacy code to inject this as metadata + // for consumption by public code in the new platform. + 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 cc1c94d799be6..694d0fca97a2c 100644 --- a/x-pack/plugins/security/server/routes/authentication/basic.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/basic.test.ts @@ -14,27 +14,21 @@ 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(); + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; - authc = authenticationMock.create(); + authc = routeParamsMock.authc; authc.isProviderEnabled.mockImplementation(provider => provider === 'basic'); mockContext = ({ @@ -43,16 +37,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.test.ts b/x-pack/plugins/security/server/routes/views/index.test.ts new file mode 100644 index 0000000000000..63e8a518c6198 --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/index.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. + */ + +import { defineViewRoutes } from '.'; + +import { routeDefinitionParamsMock } from '../index.mock'; + +describe('View routes', () => { + it('does not register Login routes if both `basic` and `token` providers are disabled', () => { + const routeParamsMock = routeDefinitionParamsMock.create(); + routeParamsMock.authc.isProviderEnabled.mockImplementation( + provider => provider !== 'basic' && provider !== 'token' + ); + + defineViewRoutes(routeParamsMock); + + expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + Array [ + "/security/account", + "/security/logged_out", + "/logout", + "/security/overwritten_session", + ] + `); + }); + + it('registers Login routes if `basic` provider is enabled', () => { + const routeParamsMock = routeDefinitionParamsMock.create(); + routeParamsMock.authc.isProviderEnabled.mockImplementation(provider => provider !== 'token'); + + defineViewRoutes(routeParamsMock); + + expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + Array [ + "/login", + "/internal/security/login_state", + "/security/account", + "/security/logged_out", + "/logout", + "/security/overwritten_session", + ] + `); + }); + + it('registers Login routes if `token` provider is enabled', () => { + const routeParamsMock = routeDefinitionParamsMock.create(); + routeParamsMock.authc.isProviderEnabled.mockImplementation(provider => provider !== 'basic'); + + defineViewRoutes(routeParamsMock); + + expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + Array [ + "/login", + "/internal/security/login_state", + "/security/account", + "/security/logged_out", + "/logout", + "/security/overwritten_session", + ] + `); + }); +}); 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..91e57aed44ab6 --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/index.ts @@ -0,0 +1,23 @@ +/* + * 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.authc.isProviderEnabled('basic') || params.authc.isProviderEnabled('token')) { + defineLoginRoutes(params); + } + + defineAccountManagementRoutes(params); + defineLoggedOutRoutes(params); + defineLogoutRoutes(params); + defineOverwrittenSessionRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/views/logged_out.test.ts b/x-pack/plugins/security/server/routes/views/logged_out.test.ts new file mode 100644 index 0000000000000..822802b62d874 --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/logged_out.test.ts @@ -0,0 +1,83 @@ +/* + * 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 { + RequestHandler, + RouteConfig, + kibanaResponseFactory, +} from '../../../../../../src/core/server'; +import { Authentication } from '../../authentication'; +import { defineLoggedOutRoutes } from './logged_out'; + +import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../index.mock'; + +describe('LoggedOut view routes', () => { + let authc: jest.Mocked; + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + authc = routeParamsMock.authc; + + defineLoggedOutRoutes(routeParamsMock); + + const [ + loggedOutRouteConfig, + loggedOutRouteHandler, + ] = routeParamsMock.router.get.mock.calls.find( + ([{ path }]) => path === '/security/logged_out' + )!; + + routeConfig = loggedOutRouteConfig; + routeHandler = loggedOutRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.validate).toBe(false); + }); + + it('redirects user to the root page if they have a session already.', async () => { + authc.getSessionInfo.mockResolvedValue({ + provider: 'basic', + now: 0, + idleTimeoutExpiration: null, + lifespanExpiration: null, + }); + + const request = httpServerMock.createKibanaRequest(); + + await expect(routeHandler({} as any, request, kibanaResponseFactory)).resolves.toEqual({ + options: { headers: { location: '/mock-server-basepath/' } }, + status: 302, + }); + + expect(authc.getSessionInfo).toHaveBeenCalledWith(request); + }); + + it('renders view if user does not have an active session.', async () => { + authc.getSessionInfo.mockResolvedValue(null); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { + headers: { + 'content-security-policy': + "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", + }, + }, + status: 200, + }); + + expect(authc.getSessionInfo).toHaveBeenCalledWith(request); + expect(contextMock.rendering.render).toHaveBeenCalledWith({ includeUserSettings: false }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/views/logged_out.ts b/x-pack/plugins/security/server/routes/views/logged_out.ts new file mode 100644 index 0000000000000..2f69d8c35f03e --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/logged_out.ts @@ -0,0 +1,48 @@ +/* + * 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. + */ + +/* + * 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 Logged Out view. + */ +export function defineLoggedOutRoutes({ + router, + logger, + authc, + csp, + basePath, +}: RouteDefinitionParams) { + router.get( + { + path: '/security/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) { + logger.debug('User is already authenticated, redirecting...'); + return response.redirected({ + headers: { location: `${basePath.serverBasePath}/` }, + }); + } + + 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.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts new file mode 100644 index 0000000000000..d14aa226e17ba --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -0,0 +1,197 @@ +/* + * 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 { URL } from 'url'; +import { Type } from '@kbn/config-schema'; +import { + RequestHandler, + RouteConfig, + kibanaResponseFactory, + IRouter, +} from '../../../../../../src/core/server'; +import { SecurityLicense } from '../../../common/licensing'; +import { Authentication } from '../../authentication'; +import { defineLoginRoutes } from './login'; + +import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../index.mock'; + +describe('Login view routes', () => { + let authc: jest.Mocked; + let router: jest.Mocked; + let license: jest.Mocked; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + authc = routeParamsMock.authc; + router = routeParamsMock.router; + license = routeParamsMock.license; + + defineLoginRoutes(routeParamsMock); + }); + + describe('View route', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [loginRouteConfig, loginRouteHandler] = router.get.mock.calls.find( + ([{ path }]) => path === '/login' + )!; + + routeConfig = loginRouteConfig; + routeHandler = loginRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + + expect(routeConfig.validate).toEqual({ + body: undefined, + query: expect.any(Type), + params: undefined, + }); + + const queryValidator = (routeConfig.validate as any).query as Type; + expect(queryValidator.validate({})).toEqual({}); + + expect(queryValidator.validate({ next: 'some-next' })).toEqual({ next: 'some-next' }); + expect(queryValidator.validate({ msg: 'some-msg' })).toEqual({ msg: 'some-msg' }); + expect(queryValidator.validate({ next: 'some-next', msg: 'some-msg', unknown: 1 })).toEqual({ + next: 'some-next', + msg: 'some-msg', + unknown: 1, + }); + + expect(() => queryValidator.validate({ next: 1 })).toThrowErrorMatchingInlineSnapshot( + `"[next]: expected value of type [string] but got [number]"` + ); + + expect(() => queryValidator.validate({ msg: 1 })).toThrowErrorMatchingInlineSnapshot( + `"[msg]: expected value of type [string] but got [number]"` + ); + }); + + it('redirects user to the root page if they have a session already or login is disabled.', async () => { + for (const { query, expectedLocation } of [ + { query: {}, expectedLocation: '/mock-server-basepath/' }, + { + query: { next: '/mock-server-basepath/app/kibana' }, + expectedLocation: '/mock-server-basepath/app/kibana', + }, + { + query: { next: 'http://evil.com/mock-server-basepath/app/kibana' }, + expectedLocation: '/mock-server-basepath/', + }, + ]) { + const request = httpServerMock.createKibanaRequest({ query }); + (request as any).url = new URL( + `${request.url.path}${request.url.search}`, + 'https://kibana.co' + ); + + // Redirect if user has an active session even if `showLogin` is `true`. + authc.getSessionInfo.mockResolvedValue({ + provider: 'basic', + now: 0, + idleTimeoutExpiration: null, + lifespanExpiration: null, + }); + license.getFeatures.mockReturnValue({ showLogin: true } as any); + await expect(routeHandler({} as any, request, kibanaResponseFactory)).resolves.toEqual({ + options: { headers: { location: `${expectedLocation}` } }, + status: 302, + }); + + // Redirect if `showLogin` is `false` even if user doesn't have an active session even. + authc.getSessionInfo.mockResolvedValue(null); + license.getFeatures.mockReturnValue({ showLogin: false } as any); + await expect(routeHandler({} as any, request, kibanaResponseFactory)).resolves.toEqual({ + options: { headers: { location: `${expectedLocation}` } }, + status: 302, + }); + } + }); + + it('renders view if user does not have an active session and login page can be shown.', async () => { + authc.getSessionInfo.mockResolvedValue(null); + license.getFeatures.mockReturnValue({ showLogin: true } as any); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { + headers: { + 'content-security-policy': + "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'", + }, + }, + status: 200, + }); + + expect(authc.getSessionInfo).toHaveBeenCalledWith(request); + expect(contextMock.rendering.render).toHaveBeenCalledWith({ includeUserSettings: false }); + }); + }); + + describe('Login state route', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [loginStateRouteConfig, loginStateRouteHandler] = router.get.mock.calls.find( + ([{ path }]) => path === '/internal/security/login_state' + )!; + + routeConfig = loginStateRouteConfig; + routeHandler = loginStateRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.validate).toBe(false); + }); + + it('returns only required license features.', async () => { + license.getFeatures.mockReturnValue({ + allowLogin: true, + allowRbac: false, + allowRoleDocumentLevelSecurity: true, + allowRoleFieldLevelSecurity: false, + layout: 'error-es-unavailable', + showLinks: false, + showRoleMappingsManagement: true, + showLogin: true, + }); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { body: { allowLogin: true, layout: 'error-es-unavailable', showLogin: true } }, + payload: { allowLogin: true, layout: 'error-es-unavailable', showLogin: true }, + status: 200, + }); + }); + + it('returns `form` layout if it is not specified in the license.', async () => { + license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { body: { allowLogin: true, layout: 'form', showLogin: true } }, + payload: { allowLogin: true, layout: 'form', showLogin: true }, + status: 200, + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/views/login.ts b/x-pack/plugins/security/server/routes/views/login.ts new file mode 100644 index 0000000000000..e2e162d298e45 --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/login.ts @@ -0,0 +1,64 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { parseNext } from '../../../common/parse_next'; +import { RouteDefinitionParams } from '..'; + +/** + * 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 }, + }, + 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; + + // 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) }, + }); + } + + return response.ok({ + body: await context.core.rendering.render({ includeUserSettings: false }), + headers: { 'content-security-policy': csp.header }, + }); + } + ); + + 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 new file mode 100644 index 0000000000000..8fa8e689a1c38 --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/logout.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 { RouteDefinitionParams } from '..'; + +/** + * 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 new file mode 100644 index 0000000000000..c21ab1c207362 --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/overwritten_session.ts @@ -0,0 +1,22 @@ +/* + * 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 Overwritten Session view. + */ +export function defineOverwrittenSessionRoutes({ router, csp }: RouteDefinitionParams) { + router.get( + { path: '/security/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 }, + }); + } + ); +} diff --git a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts index 55853f8b0fbde..b561c9ea47513 100644 --- a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts +++ b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts @@ -242,7 +242,7 @@ export default function({ getService }: FtrProviderContext) { expect(cookies).to.have.length(1); checkCookieIsCleared(request.cookie(cookies[0])!); - expect(logoutResponse.headers.location).to.be('/logged_out'); + expect(logoutResponse.headers.location).to.be('/security/logged_out'); // Token that was stored in the previous cookie should be invalidated as well and old // session cookie should not allow API access. diff --git a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts index 6cb92585de36e..fe772a3b1d460 100644 --- a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts +++ b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts @@ -290,7 +290,7 @@ export default function({ getService }: FtrProviderContext) { expect(cookies).to.have.length(1); checkCookieIsCleared(request.cookie(cookies[0])!); - expect(logoutResponse.headers.location).to.be('/logged_out'); + expect(logoutResponse.headers.location).to.be('/security/logged_out'); }); it('should redirect to home page if session cookie is not provided', async () => { diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.ts b/x-pack/test/saml_api_integration/apis/security/saml_login.ts index b8296aa703607..e49d95f2ec6c2 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.ts +++ b/x-pack/test/saml_api_integration/apis/security/saml_login.ts @@ -728,7 +728,7 @@ export default function({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .set('Cookie', existingSessionCookie.cookieString()) .send({ SAMLResponse: await createSAMLResponse({ username: newUsername }) }) - .expect('location', '/overwritten_session') + .expect('location', '/security/overwritten_session') .expect(302); const newSessionCookie = request.cookie(