Migrate security chromeless views to Kibana Platform plugin (#54021)

Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com>
This commit is contained in:
Aleh Zasypkin 2020-03-04 09:35:52 +01:00 committed by GitHub
parent 5a21805078
commit 18c3e8caf8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
123 changed files with 2646 additions and 1913 deletions

View file

@ -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',

View file

@ -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<AuthenticatedUser>;
}

View file

@ -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,
},
};
});
},
});

View file

@ -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<AuthenticatedUser>;
}
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<string, any>) =>
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)),
});
},
});

View file

@ -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: '<div />',
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<unknown>) => any) {
return function interceptor(response: ng.IHttpResponse<unknown>) {
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),
};
});
});
}

View file

@ -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),
};
});
});

View file

@ -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');
});

View file

@ -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';

View file

@ -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();
};
});

View file

@ -1,2 +0,0 @@
// Login styles
@import './login/index';

View file

@ -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: '<div id="userProfileReactRoot" />',
k7Breadcrumbs: () => [
{
text: i18n.translate('xpack.security.account.breadcrumb', {
defaultMessage: 'Account Management',
}),
},
],
controllerAs: 'accountController',
controller($scope) {
$scope.$$postDigest(() => {
const domNode = document.getElementById('userProfileReactRoot');
render(
<npStart.plugins.security.__legacyCompat.account_management.AccountManagementPage />,
domNode
);
$scope.$on('$destroy', () => unmountComponentAtNode(domNode));
});
},
});

View file

@ -1 +0,0 @@
<div id="reactLoggedOutRoot" />

View file

@ -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(
<I18nContext>
<AuthenticationStatePage
title={
<FormattedMessage
id="xpack.security.loggedOut.title"
defaultMessage="Successfully logged out"
/>
}
>
<EuiButton href={chrome.addBasePath('/')}>
<FormattedMessage id="xpack.security.loggedOut.login" defaultMessage="Log in" />
</EuiButton>
</AuthenticationStatePage>
</I18nContext>,
domNode
);
});
});

View file

@ -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';

View file

@ -1 +0,0 @@
@import './login_page/index';

View file

@ -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<LoginState>) => {
return {
allowLogin: true,
layout: 'form',
...options,
} as LoginState;
};
describe('BasicLoginForm', () => {
it('renders as expected', () => {
const mockHttp = createMockHttp();
const mockWindow = {};
const loginState = createLoginState();
expect(
shallowWithIntl(
<BasicLoginForm.WrappedComponent
http={mockHttp}
window={mockWindow}
loginState={loginState}
next={''}
intl={null as any}
loginAssistanceMessage=""
/>
)
).toMatchSnapshot();
});
it('renders an info message when provided', () => {
const mockHttp = createMockHttp();
const mockWindow = {};
const loginState = createLoginState();
const wrapper = shallowWithIntl(
<BasicLoginForm.WrappedComponent
http={mockHttp}
window={mockWindow}
loginState={loginState}
next={''}
infoMessage={'Hey this is an info message'}
intl={null as any}
loginAssistanceMessage=""
/>
);
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(
<BasicLoginForm.WrappedComponent
http={mockHttp}
window={mockWindow}
loginState={loginState}
next={''}
intl={null as any}
loginAssistanceMessage=""
/>
);
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.`
);
});
});

View file

@ -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`] = `
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter loginWelcome__contentDisabledForm"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="loginWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Kibana"
id="xpack.security.loginPage.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiText
className="loginWelcome__subtitle"
color="subdued"
size="s"
>
<p>
<FormattedMessage
defaultMessage="Your window into the Elastic Stack"
id="xpack.security.loginPage.welcomeDescription"
values={Object {}}
/>
</p>
</EuiText>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body loginWelcome__contentDisabledForm"
>
<EuiFlexGroup
gutterSize="l"
>
<EuiFlexItem>
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="See the Kibana logs for details and try reloading the page."
id="xpack.security.loginPage.esUnavailableMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="Cannot connect to the Elasticsearch cluster"
id="xpack.security.loginPage.esUnavailableTitle"
values={Object {}}
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
`;
exports[`LoginPage disabled form states renders as expected when an unknown loginState layout is provided 1`] = `
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter loginWelcome__contentDisabledForm"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="loginWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Kibana"
id="xpack.security.loginPage.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiText
className="loginWelcome__subtitle"
color="subdued"
size="s"
>
<p>
<FormattedMessage
defaultMessage="Your window into the Elastic Stack"
id="xpack.security.loginPage.welcomeDescription"
values={Object {}}
/>
</p>
</EuiText>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body loginWelcome__contentDisabledForm"
>
<EuiFlexGroup
gutterSize="l"
>
<EuiFlexItem>
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="Refer to the Kibana logs for more details and refresh to try again."
id="xpack.security.loginPage.unknownLayoutMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="Unsupported login form layout."
id="xpack.security.loginPage.unknownLayoutTitle"
values={Object {}}
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
`;
exports[`LoginPage disabled form states renders as expected when loginAssistanceMessage is set 1`] = `
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="loginWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Kibana"
id="xpack.security.loginPage.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiText
className="loginWelcome__subtitle"
color="subdued"
size="s"
>
<p>
<FormattedMessage
defaultMessage="Your window into the Elastic Stack"
id="xpack.security.loginPage.welcomeDescription"
values={Object {}}
/>
</p>
</EuiText>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body"
>
<EuiFlexGroup
gutterSize="l"
>
<EuiFlexItem>
<InjectIntl(BasicLoginFormUI)
http={
Object {
"post": [MockFunction],
}
}
isSecureConnection={false}
loginAssistanceMessage="This is an *important* message"
loginState={
Object {
"allowLogin": true,
"layout": "form",
}
}
next=""
requiresSecureConnection={false}
window={Object {}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
`;
exports[`LoginPage disabled form states renders as expected when secure cookies are required but not present 1`] = `
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter loginWelcome__contentDisabledForm"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="loginWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Kibana"
id="xpack.security.loginPage.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiText
className="loginWelcome__subtitle"
color="subdued"
size="s"
>
<p>
<FormattedMessage
defaultMessage="Your window into the Elastic Stack"
id="xpack.security.loginPage.welcomeDescription"
values={Object {}}
/>
</p>
</EuiText>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body loginWelcome__contentDisabledForm"
>
<EuiFlexGroup
gutterSize="l"
>
<EuiFlexItem>
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="Contact your system administrator."
id="xpack.security.loginPage.requiresSecureConnectionMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="A secure connection is required for log in"
id="xpack.security.loginPage.requiresSecureConnectionTitle"
values={Object {}}
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
`;
exports[`LoginPage disabled form states renders as expected when xpack is not available 1`] = `
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter loginWelcome__contentDisabledForm"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="loginWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Kibana"
id="xpack.security.loginPage.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiText
className="loginWelcome__subtitle"
color="subdued"
size="s"
>
<p>
<FormattedMessage
defaultMessage="Your window into the Elastic Stack"
id="xpack.security.loginPage.welcomeDescription"
values={Object {}}
/>
</p>
</EuiText>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body loginWelcome__contentDisabledForm"
>
<EuiFlexGroup
gutterSize="l"
>
<EuiFlexItem>
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="To use the full set of free features in this distribution of Kibana, please update Elasticsearch to the default distribution."
id="xpack.security.loginPage.xpackUnavailableMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="Cannot connect to the Elasticsearch cluster currently configured for Kibana."
id="xpack.security.loginPage.xpackUnavailableTitle"
values={Object {}}
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
`;
exports[`LoginPage enabled form state renders as expected 1`] = `
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="loginWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Kibana"
id="xpack.security.loginPage.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiText
className="loginWelcome__subtitle"
color="subdued"
size="s"
>
<p>
<FormattedMessage
defaultMessage="Your window into the Elastic Stack"
id="xpack.security.loginPage.welcomeDescription"
values={Object {}}
/>
</p>
</EuiText>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body"
>
<EuiFlexGroup
gutterSize="l"
>
<EuiFlexItem>
<InjectIntl(BasicLoginFormUI)
http={
Object {
"post": [MockFunction],
}
}
isSecureConnection={false}
loginAssistanceMessage=""
loginState={
Object {
"allowLogin": true,
"layout": "form",
}
}
next=""
requiresSecureConnection={false}
window={Object {}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
`;

View file

@ -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<LoginState>) => {
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(<LoginPage {...props} />)).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(<LoginPage {...props} />)).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(<LoginPage {...props} />)).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(<LoginPage {...props} />)).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(<LoginPage {...props} />)).toMatchSnapshot();
});
});
describe('enabled form state', () => {
it('renders as expected', () => {
const props = {
http: createMockHttp(),
window: {},
next: '',
loginState: createLoginState(),
isSecureConnection: false,
requiresSecureConnection: false,
loginAssistanceMessage: '',
};
expect(shallow(<LoginPage {...props} />)).toMatchSnapshot();
});
});
});

View file

@ -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('<div id="reactLoginRoot" />')
.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(
<I18nContext>
<LoginPage
http={$http}
window={$window}
infoMessage={get(messageMap, msgQueryParam)}
loginState={loginState}
isSecureConnection={isSecure}
requiresSecureConnection={secureCookies}
loginAssistanceMessage={loginAssistanceMessage}
next={next}
/>
</I18nContext>,
domNode
);
});
}
);

View file

@ -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';

View file

@ -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}`);
});

View file

@ -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';

View file

@ -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('<div id="reactOverwrittenSessionRoot" />')
.setRootController('overwritten_session', ($scope: any) => {
$scope.$$postDigest(() => {
((npSetup.plugins as unknown) as { security: SecurityPluginSetup }).security.authc
.getCurrentUser()
.then((user: AuthenticatedUser) => {
const overwrittenSessionPage = (
<I18nContext>
<AuthenticationStatePage
title={
<FormattedMessage
id="xpack.security.overwrittenSession.title"
defaultMessage="You previously logged in as a different user."
/>
}
>
<EuiButton href={chrome.addBasePath('/')}>
<FormattedMessage
id="xpack.security.overwrittenSession.continueAsUserText"
defaultMessage="Continue as {username}"
values={{ username: user.username }}
/>
</EuiButton>
</AuthenticationStatePage>
</I18nContext>
);
render(overwrittenSessionPage, document.getElementById('reactOverwrittenSessionRoot'));
});
});
});

View file

@ -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('/');
});
});
});

View file

@ -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 || '');
}

View file

@ -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,
},
});
}

View file

@ -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,
},
});
}

View file

@ -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,
},
});
}

View file

@ -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'));
},
});
}

View file

@ -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'
);
},
};

View file

@ -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',
},
},
});

View file

@ -6,4 +6,4 @@
export { SecurityLicenseService, SecurityLicense } from './license_service';
export { SecurityLicenseFeatures } from './license_features';
export { LoginLayout, SecurityLicenseFeatures } from './license_features';

View file

@ -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;
}

View file

@ -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.',
});
});

View file

@ -90,7 +90,6 @@ export class SecurityLicenseService {
allowRoleDocumentLevelSecurity: false,
allowRoleFieldLevelSecurity: false,
allowRbac: false,
linksMessage: 'Access is denied because Security is disabled in Elasticsearch.',
};
}

View file

@ -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,
});
});
});

View file

@ -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),
});
},
});
},
});

View file

@ -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
</EuiPage>
);
};
export function renderAccountManagementPage(
i18nStart: CoreStart['i18n'],
element: Element,
props: Props
) {
ReactDOM.render(
<i18nStart.Context>
<AccountManagementPage {...props} />
</i18nStart.Context>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
}

View file

@ -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';

View file

@ -0,0 +1,5 @@
// Component styles
@import './components/index';
// Login styles
@import './login/index';

View file

@ -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<PluginStartDependencies>['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 };
}
}

View file

@ -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';

View file

@ -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';

View file

@ -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,
});
});
});

View file

@ -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 });
},
});
},
});

View file

@ -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 (
<AuthenticationStatePage
title={
<FormattedMessage
id="xpack.security.loggedOut.title"
defaultMessage="Successfully logged out"
/>
}
>
<EuiButton href={basePath.prepend('/')}>
<FormattedMessage id="xpack.security.loggedOut.login" defaultMessage="Log in" />
</EuiButton>
</AuthenticationStatePage>
);
}
export function renderLoggedOutPage(i18nStart: CoreStart['i18n'], element: Element, props: Props) {
ReactDOM.render(
<i18nStart.Context>
<LoggedOutPage {...props} />
</i18nStart.Context>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
}

View file

@ -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`] = `
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="See the Kibana logs for details and try reloading the page."
id="xpack.security.loginPage.esUnavailableMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="Cannot connect to the Elasticsearch cluster"
id="xpack.security.loginPage.esUnavailableTitle"
values={Object {}}
/>
}
/>
`;
exports[`LoginPage disabled form states renders as expected when an unknown loginState layout is provided 1`] = `
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="Refer to the Kibana logs for more details and refresh to try again."
id="xpack.security.loginPage.unknownLayoutMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="Unsupported login form layout."
id="xpack.security.loginPage.unknownLayoutTitle"
values={Object {}}
/>
}
/>
`;
exports[`LoginPage disabled form states renders as expected when secure connection is required but not present 1`] = `
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="Contact your system administrator."
id="xpack.security.loginPage.requiresSecureConnectionMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="A secure connection is required for log in"
id="xpack.security.loginPage.requiresSecureConnectionTitle"
values={Object {}}
/>
}
/>
`;
exports[`LoginPage disabled form states renders as expected when xpack is not available 1`] = `
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="To use the full set of free features in this distribution of Kibana, please update Elasticsearch to the default distribution."
id="xpack.security.loginPage.xpackUnavailableMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="Cannot connect to the Elasticsearch cluster currently configured for Kibana."
id="xpack.security.loginPage.xpackUnavailableTitle"
values={Object {}}
/>
}
/>
`;
exports[`LoginPage enabled form state renders as expected 1`] = `
<BasicLoginForm
http={
Object {
"addLoadingCountSource": [MockFunction],
"get": [MockFunction],
}
}
loginAssistanceMessage=""
/>
`;
exports[`LoginPage enabled form state renders as expected when info message is set 1`] = `
<BasicLoginForm
http={
Object {
"addLoadingCountSource": [MockFunction],
"get": [MockFunction],
}
}
infoMessage="Your session has timed out. Please log in again."
loginAssistanceMessage=""
/>
`;
exports[`LoginPage enabled form state renders as expected when loginAssistanceMessage is set 1`] = `
<BasicLoginForm
http={
Object {
"addLoadingCountSource": [MockFunction],
"get": [MockFunction],
}
}
infoMessage="Your session has timed out. Please log in again."
loginAssistanceMessage="This is an *important* message"
/>
`;
exports[`LoginPage page renders as expected 1`] = `
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="loginWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Kibana"
id="xpack.security.loginPage.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiText
className="loginWelcome__subtitle"
color="subdued"
size="s"
>
<p>
<FormattedMessage
defaultMessage="Your window into the Elastic Stack"
id="xpack.security.loginPage.welcomeDescription"
values={Object {}}
/>
</p>
</EuiText>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body"
>
<EuiFlexGroup
gutterSize="l"
>
<EuiFlexItem>
<BasicLoginForm
http={
Object {
"addLoadingCountSource": [MockFunction],
"get": [MockFunction],
}
}
loginAssistanceMessage=""
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
`;

View file

@ -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(
<BasicLoginForm http={coreMock.createStart().http} loginAssistanceMessage="" />
)
).toMatchSnapshot();
});
it('renders an info message when provided.', () => {
const wrapper = shallowWithIntl(
<BasicLoginForm
http={coreMock.createStart().http}
infoMessage={'Hey this is an info message'}
loginAssistanceMessage=""
/>
);
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(<BasicLoginForm http={mockHTTP} loginAssistanceMessage="" />);
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(<BasicLoginForm http={mockHTTP} loginAssistanceMessage="" />);
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(<BasicLoginForm http={mockHTTP} loginAssistanceMessage="" />);
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);
});
});

View file

@ -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<Props, State> {
export class BasicLoginForm extends Component<Props, State> {
public state = {
hasError: false,
isLoading: false,
@ -175,7 +180,7 @@ class BasicLoginFormUI extends Component<Props, State> {
});
};
private submit = (e: MouseEvent<HTMLButtonElement> | FormEvent<HTMLFormElement>) => {
private submit = async (e: MouseEvent<HTMLButtonElement> | FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!this.isFormValid()) {
@ -187,34 +192,28 @@ class BasicLoginFormUI extends Component<Props, State> {
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 || {};
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.',
});
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,
});
}
);
this.setState({
hasError: true,
message,
isLoading: false,
});
}
};
}
export const BasicLoginForm = injectI18n(BasicLoginFormUI);

View file

@ -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';

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
import './login';
export { loginApp } from './login_app';

View file

@ -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,
});
});
});

View file

@ -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<ConfigType, 'loginAssistanceMessage'>;
}
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,
});
},
});
},
});

View file

@ -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<LoginState>) => {
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(
<LoginPage
http={httpMock}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
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(
<LoginPage
http={httpMock}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={true}
/>
);
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(
<LoginPage
http={httpMock}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
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(
<LoginPage
http={httpMock}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
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(
<LoginPage
http={httpMock}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
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(
<LoginPage
http={httpMock}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
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(
<LoginPage
http={httpMock}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
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(
<LoginPage
http={httpMock}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage="This is an *important* message"
requiresSecureConnection={false}
/>
);
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(
<LoginPage
http={httpMock}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
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(
<LoginPage
http={httpMock}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
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);
});
});
});

View file

@ -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;
}
export class LoginPage extends Component<Props, {}> {
interface State {
loginState: LoginState | null;
}
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<Props, State> {
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<Props, {}> {
</header>
<div className={contentBodyClasses}>
<EuiFlexGroup gutterSize="l">
<EuiFlexItem>{this.getLoginForm()}</EuiFlexItem>
<EuiFlexItem>{this.getLoginForm({ isSecureConnection, layout })}</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
);
}
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 (
<DisabledLoginForm
title={
@ -110,10 +144,17 @@ export class LoginPage extends Component<Props, {}> {
);
}
const layout = this.props.loginState.layout;
switch (layout) {
case 'form':
return <BasicLoginForm {...this.props} />;
return (
<BasicLoginForm
http={this.props.http}
infoMessage={infoMessageMap.get(
parse(window.location.href, true).query.msg?.toString()
)}
loginAssistanceMessage={this.props.loginAssistanceMessage}
/>
);
case 'error-es-unavailable':
return (
<DisabledLoginForm
@ -168,3 +209,14 @@ export class LoginPage extends Component<Props, {}> {
}
};
}
export function renderLoginPage(i18nStart: CoreStart['i18n'], element: Element, props: Props) {
ReactDOM.render(
<i18nStart.Context>
<LoginPage {...props} />
</i18nStart.Context>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
}

View file

@ -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;

View file

@ -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';

View file

@ -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');
});
});

View file

@ -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 () => {};
},
});
},
});

View file

@ -0,0 +1,135 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`OverwrittenSessionPage renders as expected 1`] = `
<AuthenticationStatePage
title={
<FormattedMessage
defaultMessage="You previously logged in as a different user."
id="xpack.security.overwrittenSession.title"
values={Object {}}
/>
}
>
<div
className="secAuthenticationStatePage"
>
<header
className="secAuthenticationStatePage__header"
>
<div
className="secAuthenticationStatePage__content eui-textCenter"
>
<EuiSpacer
size="xxl"
>
<div
className="euiSpacer euiSpacer--xxl"
/>
</EuiSpacer>
<span
className="secAuthenticationStatePage__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
>
<EuiIconLogoKibana
aria-hidden={true}
className="euiIcon euiIcon--xxLarge euiIcon-isLoaded"
focusable="false"
role="img"
style={null}
>
<svg
aria-hidden={true}
className="euiIcon euiIcon--xxLarge euiIcon-isLoaded"
focusable="false"
height={32}
role="img"
style={null}
viewBox="0 0 32 32"
width={32}
xmlns="http://www.w3.org/2000/svg"
>
<g
fill="none"
fillRule="evenodd"
>
<path
d="M4 0v28.789L28.935.017z"
fill="#F04E98"
/>
<path
className="euiIcon__fillNegative"
d="M4 12v16.789l11.906-13.738A24.721 24.721 0 004 12"
/>
<path
d="M18.479 16.664L6.268 30.754l-1.073 1.237h23.191c-1.252-6.292-4.883-11.719-9.908-15.327"
fill="#00BFB3"
/>
</g>
</svg>
</EuiIconLogoKibana>
</EuiIcon>
</span>
<EuiTitle
className="secAuthenticationStatePage__title"
size="l"
>
<h1
className="euiTitle euiTitle--large secAuthenticationStatePage__title"
>
<FormattedMessage
defaultMessage="You previously logged in as a different user."
id="xpack.security.overwrittenSession.title"
values={Object {}}
>
You previously logged in as a different user.
</FormattedMessage>
</h1>
</EuiTitle>
<EuiSpacer
size="xl"
>
<div
className="euiSpacer euiSpacer--xl"
/>
</EuiSpacer>
</div>
</header>
<div
className="secAuthenticationStatePage__content eui-textCenter"
>
<EuiButton
href="/mock-base-path/"
>
<a
className="euiButton euiButton--primary"
href="/mock-base-path/"
rel="noreferrer"
>
<span
className="euiButton__content"
>
<span
className="euiButton__text"
>
<FormattedMessage
defaultMessage="Continue as {username}"
id="xpack.security.overwrittenSession.continueAsUserText"
values={
Object {
"username": "mock-user",
}
}
>
Continue as mock-user
</FormattedMessage>
</span>
</span>
</a>
</EuiButton>
</div>
</div>
</AuthenticationStatePage>
`;

View file

@ -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';

View file

@ -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,
});
});
});

View file

@ -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,
});
},
});
},
});

View file

@ -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(
<OverwrittenSessionPage basePath={basePathMock} authc={authenticationSetupMock} />
);
// 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();
});
});

View file

@ -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<string | null>(null);
useEffect(() => {
authc.getCurrentUser().then(user => setUsername(user.username));
}, [authc]);
if (username == null) {
return null;
}
return (
<AuthenticationStatePage
title={
<FormattedMessage
id="xpack.security.overwrittenSession.title"
defaultMessage="You previously logged in as a different user."
/>
}
>
<EuiButton href={basePath.prepend('/')}>
<FormattedMessage
id="xpack.security.overwrittenSession.continueAsUserText"
defaultMessage="Continue as {username}"
values={{ username }}
/>
</EuiButton>
</AuthenticationStatePage>
);
}
export function renderOverwrittenSessionPage(
i18nStart: CoreStart['i18n'],
element: Element,
props: Props
) {
ReactDOM.render(
<i18nStart.Context>
<OverwrittenSessionPage {...props} />
</i18nStart.Context>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
}

View file

@ -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;
}

View file

@ -1,4 +1,7 @@
$secFormWidth: 460px;
// Authentication styles
@import './authentication/index';
// Management styles
@import './management/index';

View file

@ -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<SecurityPluginSetup, SecurityPluginStart> = () =>
new SecurityPlugin();
export const plugin: PluginInitializer<SecurityPluginSetup, SecurityPluginStart> = (
initializerContext: PluginInitializerContext
) => new SecurityPlugin(initializerContext);

View file

@ -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();

View file

@ -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(
<I18nContext>

View file

@ -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<PluginStartDependencies>, {
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<PluginStartDependencies>,
{ 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<PluginStartDependencies>,
{
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<PluginStartDependencies>,
{ 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<PluginStartDependencies>,
{ licensing: licensingMock.createSetup() }
);
plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), {
data: {} as DataPublicPluginStart,
});
expect(() => plugin.stop()).not.toThrow();
});
});
});

View file

@ -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<ConfigType>();
}
public setup(
core: CoreSetup<PluginStartDependencies>,
{ 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: () => (
<core.i18n.Context>
<AccountManagementPage
authc={this.authc}
notifications={core.notifications}
userAPIClient={new UserAPIClient(core.http)}
/>
</core.i18n.Context>
),
},
},
};
}
public stop() {

View file

@ -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;

View file

@ -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);

View file

@ -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`
);
}
/**

View file

@ -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);

View file

@ -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}`);

View file

@ -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);

View file

@ -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`
);
}
/**

View file

@ -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,21 +1029,8 @@ describe('SAMLAuthenticationProvider', () => {
accessToken: 'x-saml-token',
refreshToken: 'x-saml-refresh-token',
})
).resolves.toEqual(DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out'));
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', {
body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' },
});
});
it('redirects to /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')
).resolves.toEqual(
DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out')
);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
@ -1046,13 +1039,28 @@ 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 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/security/logged_out')
);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', {
body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' },
});
});
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);

View file

@ -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 }
);
}

View file

@ -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 {

Some files were not shown because too many files have changed in this diff Show more