Adding "Successfully logged out" page (#23890)

* Adding very basic place for the logged out page

* Redirecting to logged_out when we aren't using SLO

* Basing styles on the login styles

* Fixing linting errors

* Responding to PR feedback

* Fixing issue with the basepath and the login link

* Adding proper i18n prefix

* Updating unit tests
This commit is contained in:
Brandon Kobel 2018-11-01 05:33:32 -07:00 committed by GitHub
parent 70f1a4094e
commit 8cbafdf5fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 227 additions and 26 deletions

View file

@ -25,7 +25,7 @@ import { downloadReport } from '../lib/download_report';
uiModules.get('kibana')
.run((Private, reportingPollConfig) => {
// Don't show users any reporting toasts until they're logged in.
if (Private(PathProvider).isLoginOrLogout()) {
if (Private(PathProvider).isUnauthenticated()) {
return;
}

View file

@ -12,6 +12,7 @@ import { initPublicRolesApi } from './server/routes/api/public/roles';
import { initIndicesApi } from './server/routes/api/v1/indices';
import { initLoginView } from './server/routes/views/login';
import { initLogoutView } from './server/routes/views/logout';
import { initLoggedOutView } from './server/routes/views/logged_out';
import { validateConfig } from './server/lib/validate_config';
import { authenticateFactory } from './server/lib/auth_redirect';
import { checkLicense } from './server/lib/check_license';
@ -67,6 +68,11 @@ export const security = (kibana) => new kibana.Plugin({
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',
@ -165,6 +171,7 @@ export const security = (kibana) => new kibana.Plugin({
initIndicesApi(server);
initLoginView(server, xpackMainPlugin);
initLogoutView(server);
initLoggedOutView(server);
server.injectUiAppVars('login', () => {

View file

@ -21,7 +21,7 @@ const SESSION_TIMEOUT_GRACE_PERIOD_MS = 5000;
const module = uiModules.get('security', []);
module.config(($httpProvider) => {
$httpProvider.interceptors.push(($timeout, $window, $q, $injector, sessionTimeout, Notifier, Private, autoLogout) => {
const isLoginOrLogout = Private(PathProvider).isLoginOrLogout();
const isUnauthenticated = Private(PathProvider).isUnauthenticated();
const notifier = new Notifier();
const notificationLifetime = 60 * 1000;
const notificationOptions = {
@ -61,7 +61,7 @@ module.config(($httpProvider) => {
function interceptorFactory(responseHandler) {
return function interceptor(response) {
if (!isLoginOrLogout && !isSystemApiRequest(response.config) && sessionTimeout !== null) {
if (!isUnauthenticated && !isSystemApiRequest(response.config) && sessionTimeout !== null) {
clearNotifications();
scheduleNotification();
}

View file

@ -21,10 +21,10 @@ function isUnauthorizedResponseAllowed(response) {
const module = uiModules.get('security');
module.factory('onUnauthorizedResponse', ($q, $window, $injector, Private, autoLogout) => {
const isLoginOrLogout = Private(PathProvider).isLoginOrLogout();
const isUnauthenticated = Private(PathProvider).isUnauthenticated();
function interceptorFactory(responseHandler) {
return function interceptor(response) {
if (response.status === 401 && !isUnauthorizedResponseAllowed(response) && !isLoginOrLogout) return autoLogout();
if (response.status === 401 && !isUnauthorizedResponseAllowed(response) && !isUnauthenticated) return autoLogout();
return responseHandler(response);
};
}

View file

@ -1,4 +1,7 @@
@import 'ui/public/styles/styling_constants';
// Logged out styles
@import './views/logged_out/index';
// Login styles
@import './views/login/index';
@import './views/login/index';

View file

@ -0,0 +1 @@
@import 'logged_out';

View file

@ -0,0 +1,64 @@
.loggedOut {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: $euiZLevel9 + 1000;
background: inherit;
background-image: linear-gradient(0deg, $euiColorLightestShade 0%, $euiColorEmptyShade 100%);
opacity: 0;
overflow: auto;
animation: loggedOut_FadeIn $euiAnimSpeedExtraSlow $euiAnimSlightResistance 0s forwards;
}
.loggedOut::before {
// SASSTODO: webpack pipeline isn't setup to handle image urls in SASS yet
// content: url(../../assets/bg_top_branded.svg);
position: absolute;
top: 0;
right: 0;
z-index: 1;
}
.loggedOut::after {
// SASSTODO: webpack pipeline isn't setup to handle image urls in SASS yet
// content: url(../../assets/bg_bottom_branded.svg);
position: fixed;
bottom: -2px; // Hides an odd space at the bottom of the svg
left: 0;
z-index: 1;
}
.loggedOut__header {
position: relative;
padding: $euiSizeXL;
z-index: 10;
}
.loggedOut__logo {
margin-bottom: $euiSizeXL;
@include kibanaCircleLogo;
@include euiBottomShadowMedium;
}
.loggedOut__content {
position: relative;
margin: auto;
max-width: 460px;
padding-left: $euiSizeXL;
padding-right: $euiSizeXL;
z-index: 10;
}
@keyframes loggedOut_FadeIn {
from {
opacity: 0;
transform: translateY(200px), scale(0.75);
}
to {
opacity: 1;
transform: translateY(0), scale(1);
}
}

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 { LoggedOutPage } from './logged_out_page';

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 { EuiButton, EuiIcon, EuiSpacer, EuiTitle } from '@elastic/eui';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import React, { Component } from 'react';
interface Props {
addBasePath: (path: string) => string;
}
export class LoggedOutPage extends Component<Props, {}> {
public render() {
return (
<I18nProvider>
<div className="loggedOut">
<header className="loggedOut__header">
<div className="loggedOut__content eui-textCenter">
<EuiSpacer size="xxl" />
<span className="loggedOut__logo">
<EuiIcon type="logoKibana" size="xxl" />
</span>
<EuiTitle size="l" className="loggedOut__title">
<h1>
<FormattedMessage
id="xpack.security.loggedOut.title"
defaultMessage="Successfully logged out"
/>
</h1>
</EuiTitle>
<EuiSpacer size="xl" />
</div>
</header>
<div className="loggedOut__content eui-textCenter">
<EuiButton href={this.props.addBasePath('/login')}>
<FormattedMessage id="xpack.security.loggedOut.login" defaultMessage="Login" />
</EuiButton>
</div>
</div>
</I18nProvider>
);
}
}

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.
*/
import './logged_out';

View file

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

View file

@ -0,0 +1,7 @@
.loggedOut::before {
content: url(../../assets/bg_top_branded.svg);
}
.loggedOut::after {
content: url(../../assets/bg_bottom_branded.svg);
}

View file

@ -0,0 +1,25 @@
/*
* 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 template from 'plugins/security/views/logged_out/logged_out.html';
import React from 'react';
import { render } from 'react-dom';
import 'ui/autoload/styles';
import chrome from 'ui/chrome';
import './logged_out.less';
import { LoggedOutPage } from './components';
chrome
.setVisible(false)
.setRootTemplate(template)
.setRootController('logout', ($scope: any) => {
$scope.$$postDigest(() => {
const domNode = document.getElementById('reactLoggedOutRoot');
render(<LoggedOutPage addBasePath={chrome.addBasePath} />, domNode);
});
});

View file

@ -31,7 +31,7 @@ const module = uiModules.get('security', ['kibana']);
module.controller('securityNavController', ($scope, ShieldUser, globalNavState, kbnBaseUrl, Private) => {
const xpackInfo = Private(XPackInfoProvider);
const showSecurityLinks = xpackInfo.get('features.security.showLinks');
if (Private(PathProvider).isLoginOrLogout() || !showSecurityLinks) return;
if (Private(PathProvider).isUnauthenticated() || !showSecurityLinks) return;
$scope.user = ShieldUser.getCurrent();
$scope.route = `${kbnBaseUrl}#/account`;
@ -54,7 +54,7 @@ chromeHeaderNavControlsRegistry.register((ShieldUser, kbnBaseUrl, Private) => ({
render(el) {
const xpackInfo = Private(XPackInfoProvider);
const showSecurityLinks = xpackInfo.get('features.security.showLinks');
if (Private(PathProvider).isLoginOrLogout() || !showSecurityLinks) return null;
if (Private(PathProvider).isUnauthenticated() || !showSecurityLinks) return null;
const props = {
user: ShieldUser.getCurrent(),

View file

@ -564,7 +564,7 @@ describe('SAMLAuthenticationProvider', () => {
expect(authenticationResult.error).to.be(failureReason);
});
it('does not redirect if `redirect` field in SAML logout response is null.', async () => {
it('redirects to /logged_out if `redirect` field in SAML logout response is null.', async () => {
const request = requestFixture();
const accessToken = 'x-saml-token';
const refreshToken = 'x-saml-refresh-token';
@ -582,10 +582,11 @@ describe('SAMLAuthenticationProvider', () => {
{ body: { token: accessToken, refresh_token: refreshToken } }
);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/logged_out');
});
it('does not redirect if `redirect` field in SAML logout response is not defined.', async () => {
it('redirects to /logged_out if `redirect` field in SAML logout response is not defined.', async () => {
const request = requestFixture();
const accessToken = 'x-saml-token';
const refreshToken = 'x-saml-refresh-token';
@ -603,7 +604,8 @@ describe('SAMLAuthenticationProvider', () => {
{ body: { token: accessToken, refresh_token: refreshToken } }
);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/logged_out');
});
it('relies on SAML logout if query string is not empty, but does not include SAMLRequest.', async () => {
@ -624,7 +626,8 @@ describe('SAMLAuthenticationProvider', () => {
{ body: { token: accessToken, refresh_token: refreshToken } }
);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/logged_out');
});
it('relies SAML invalidate call even if access token is presented.', async () => {
@ -651,10 +654,11 @@ describe('SAMLAuthenticationProvider', () => {
}
);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/logged_out');
});
it('does not redirect if `redirect` field in SAML invalidate response is null.', async () => {
it('redirects to /logged_out if `redirect` field in SAML invalidate response is null.', async () => {
const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' });
callWithInternalUser
@ -675,10 +679,11 @@ describe('SAMLAuthenticationProvider', () => {
}
);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/logged_out');
});
it('does not redirect if `redirect` field in SAML invalidate response is not defined.', async () => {
it('redirects to /logged_out if `redirect` field in SAML invalidate response is not defined.', async () => {
const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' });
callWithInternalUser
@ -699,7 +704,8 @@ describe('SAMLAuthenticationProvider', () => {
}
);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/logged_out');
});
it('redirects user to the IdP if SLO is supported by IdP in case of SP initiated logout.', async () => {

View file

@ -410,7 +410,7 @@ export class SAMLAuthenticationProvider {
return DeauthenticationResult.redirectTo(redirect);
}
return DeauthenticationResult.succeeded();
return DeauthenticationResult.redirectTo('/logged_out');
} catch(err) {
this._options.log(['debug', 'security', 'saml'], `Failed to deauthenticate user: ${err.message}`);
return DeauthenticationResult.failed(err);

View file

@ -0,0 +1,27 @@
/*
* 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(server) {
const config = server.config();
const loggedOut = server.getHiddenUiAppById('logged_out');
const cookieName = config.get('xpack.security.cookieName');
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

@ -21,7 +21,7 @@ module.factory('checkXPackInfoChange', ($q, Private) => {
const xpackInfo = Private(XPackInfoProvider);
const xpackInfoSignature = Private(XPackInfoSignatureProvider);
const debounce = Private(DebounceProvider);
const isLoginOrLogout = Private(PathProvider).isLoginOrLogout();
const isUnauthenticated = Private(PathProvider).isUnauthenticated();
let isLicenseExpirationBannerShown = false;
const notifyIfLicenseIsExpired = debounce(() => {
@ -59,7 +59,7 @@ module.factory('checkXPackInfoChange', ($q, Private) => {
* @return
*/
function interceptor(response, handleResponse) {
if (isLoginOrLogout) {
if (isUnauthenticated) {
return handleResponse(response);
}

View file

@ -15,7 +15,7 @@ function telemetryStart($injector) {
if (telemetryEnabled) {
const Private = $injector.get('Private');
// no telemetry for non-logged in users
if (Private(PathProvider).isLoginOrLogout()) { return; }
if (Private(PathProvider).isUnauthenticated()) { return; }
const $http = $injector.get('$http');
const sender = new Telemetry($injector, () => fetchTelemetry($http));

View file

@ -31,7 +31,7 @@ async function asyncInjectBanner($injector) {
}
// and no banner for non-logged in users
if (Private(PathProvider).isLoginOrLogout()) {
if (Private(PathProvider).isUnauthenticated()) {
return;
}

View file

@ -9,8 +9,8 @@ import chrome from 'ui/chrome';
export function PathProvider($window) {
const path = chrome.removeBasePath($window.location.pathname);
return {
isLoginOrLogout() {
return path === '/login' || path === '/logout';
isUnauthenticated() {
return path === '/login' || path === '/logout' || path === '/logged_out';
}
};
}

View file

@ -28,4 +28,4 @@
"jest"
]
}
}
}