mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
Better message for unanticipated authorisation errors (#113460)
* Custom message for unanticipated 401 errors * Refactor logout reasons * Fix types Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
51df1e58a3
commit
693727663e
11 changed files with 79 additions and 54 deletions
|
@ -12,3 +12,10 @@ export interface SessionInfo {
|
||||||
canBeExtended: boolean;
|
canBeExtended: boolean;
|
||||||
provider: AuthenticationProvider;
|
provider: AuthenticationProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum LogoutReason {
|
||||||
|
'SESSION_EXPIRED' = 'SESSION_EXPIRED',
|
||||||
|
'AUTHENTICATION_ERROR' = 'AUTHENTICATION_ERROR',
|
||||||
|
'LOGGED_OUT' = 'LOGGED_OUT',
|
||||||
|
'UNAUTHENTICATED' = 'UNAUTHENTICATED',
|
||||||
|
}
|
||||||
|
|
|
@ -5,5 +5,6 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type { LoginFormProps } from './login_form';
|
||||||
export { LoginForm, LoginFormMessageType } from './login_form';
|
export { LoginForm, LoginFormMessageType } from './login_form';
|
||||||
export { DisabledLoginForm } from './disabled_login_form';
|
export { DisabledLoginForm } from './disabled_login_form';
|
||||||
|
|
|
@ -5,4 +5,5 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type { LoginFormProps } from './login_form';
|
||||||
export { LoginForm, MessageType as LoginFormMessageType } from './login_form';
|
export { LoginForm, MessageType as LoginFormMessageType } from './login_form';
|
||||||
|
|
|
@ -36,7 +36,7 @@ import type { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/pu
|
||||||
import type { LoginSelector, LoginSelectorProvider } from '../../../../../common/login_state';
|
import type { LoginSelector, LoginSelectorProvider } from '../../../../../common/login_state';
|
||||||
import { LoginValidator } from './validate_login';
|
import { LoginValidator } from './validate_login';
|
||||||
|
|
||||||
interface Props {
|
export interface LoginFormProps {
|
||||||
http: HttpStart;
|
http: HttpStart;
|
||||||
notifications: NotificationsStart;
|
notifications: NotificationsStart;
|
||||||
selector: LoginSelector;
|
selector: LoginSelector;
|
||||||
|
@ -78,7 +78,7 @@ export enum PageMode {
|
||||||
LoginHelp,
|
LoginHelp,
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoginForm extends Component<Props, State> {
|
export class LoginForm extends Component<LoginFormProps, State> {
|
||||||
private readonly validator: LoginValidator;
|
private readonly validator: LoginValidator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -88,7 +88,7 @@ export class LoginForm extends Component<Props, State> {
|
||||||
*/
|
*/
|
||||||
private readonly suggestedProvider?: LoginSelectorProvider;
|
private readonly suggestedProvider?: LoginSelectorProvider;
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: LoginFormProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.validator = new LoginValidator({ shouldValidate: false });
|
this.validator = new LoginValidator({ shouldValidate: false });
|
||||||
|
|
||||||
|
@ -513,7 +513,7 @@ export class LoginForm extends Component<Props, State> {
|
||||||
);
|
);
|
||||||
|
|
||||||
window.location.href = location;
|
window.location.href = location;
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
this.props.notifications.toasts.addError(
|
this.props.notifications.toasts.addError(
|
||||||
err?.body?.message ? new Error(err?.body?.message) : err,
|
err?.body?.message ? new Error(err?.body?.message) : err,
|
||||||
{
|
{
|
||||||
|
|
|
@ -12,7 +12,6 @@ import classNames from 'classnames';
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { BehaviorSubject } from 'rxjs';
|
import { BehaviorSubject } from 'rxjs';
|
||||||
import { parse } from 'url';
|
|
||||||
|
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
import { FormattedMessage } from '@kbn/i18n/react';
|
import { FormattedMessage } from '@kbn/i18n/react';
|
||||||
|
@ -23,6 +22,8 @@ import {
|
||||||
LOGOUT_REASON_QUERY_STRING_PARAMETER,
|
LOGOUT_REASON_QUERY_STRING_PARAMETER,
|
||||||
} from '../../../common/constants';
|
} from '../../../common/constants';
|
||||||
import type { LoginState } from '../../../common/login_state';
|
import type { LoginState } from '../../../common/login_state';
|
||||||
|
import type { LogoutReason } from '../../../common/types';
|
||||||
|
import type { LoginFormProps } from './components';
|
||||||
import { DisabledLoginForm, LoginForm, LoginFormMessageType } from './components';
|
import { DisabledLoginForm, LoginForm, LoginFormMessageType } from './components';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -36,36 +37,33 @@ interface State {
|
||||||
loginState: LoginState | null;
|
loginState: LoginState | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageMap = new Map([
|
const loginFormMessages: Record<LogoutReason, NonNullable<LoginFormProps['message']>> = {
|
||||||
[
|
SESSION_EXPIRED: {
|
||||||
'SESSION_EXPIRED',
|
type: LoginFormMessageType.Info,
|
||||||
{
|
content: i18n.translate('xpack.security.login.sessionExpiredDescription', {
|
||||||
type: LoginFormMessageType.Info,
|
defaultMessage: 'Your session has timed out. Please log in again.',
|
||||||
content: i18n.translate('xpack.security.login.sessionExpiredDescription', {
|
}),
|
||||||
defaultMessage: 'Your session has timed out. Please log in again.',
|
},
|
||||||
}),
|
AUTHENTICATION_ERROR: {
|
||||||
},
|
type: LoginFormMessageType.Info,
|
||||||
],
|
content: i18n.translate('xpack.security.login.authenticationErrorDescription', {
|
||||||
[
|
defaultMessage: 'An unexpected authentication error occurred. Please log in again.',
|
||||||
'LOGGED_OUT',
|
}),
|
||||||
{
|
},
|
||||||
type: LoginFormMessageType.Info,
|
LOGGED_OUT: {
|
||||||
content: i18n.translate('xpack.security.login.loggedOutDescription', {
|
type: LoginFormMessageType.Info,
|
||||||
defaultMessage: 'You have logged out of Elastic.',
|
content: i18n.translate('xpack.security.login.loggedOutDescription', {
|
||||||
}),
|
defaultMessage: 'You have logged out of Elastic.',
|
||||||
},
|
}),
|
||||||
],
|
},
|
||||||
[
|
UNAUTHENTICATED: {
|
||||||
'UNAUTHENTICATED',
|
type: LoginFormMessageType.Danger,
|
||||||
{
|
content: i18n.translate('xpack.security.unauthenticated.errorDescription', {
|
||||||
type: LoginFormMessageType.Danger,
|
defaultMessage:
|
||||||
content: i18n.translate('xpack.security.unauthenticated.errorDescription', {
|
"We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator.",
|
||||||
defaultMessage:
|
}),
|
||||||
"We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator.",
|
},
|
||||||
}),
|
};
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
export class LoginPage extends Component<Props, State> {
|
export class LoginPage extends Component<Props, State> {
|
||||||
state = { loginState: null } as State;
|
state = { loginState: null } as State;
|
||||||
|
@ -77,7 +75,7 @@ export class LoginPage extends Component<Props, State> {
|
||||||
try {
|
try {
|
||||||
this.setState({ loginState: await this.props.http.get('/internal/security/login_state') });
|
this.setState({ loginState: await this.props.http.get('/internal/security/login_state') });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.props.fatalErrors.add(err);
|
this.props.fatalErrors.add(err as Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadingCount$.next(0);
|
loadingCount$.next(0);
|
||||||
|
@ -235,17 +233,19 @@ export class LoginPage extends Component<Props, State> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = parse(window.location.href, true).query;
|
const { searchParams } = new URL(window.location.href);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoginForm
|
<LoginForm
|
||||||
http={this.props.http}
|
http={this.props.http}
|
||||||
notifications={this.props.notifications}
|
notifications={this.props.notifications}
|
||||||
selector={selector}
|
selector={selector}
|
||||||
// @ts-expect-error Map.get is ok with getting `undefined`
|
message={
|
||||||
message={messageMap.get(query[LOGOUT_REASON_QUERY_STRING_PARAMETER]?.toString())}
|
loginFormMessages[searchParams.get(LOGOUT_REASON_QUERY_STRING_PARAMETER) as LogoutReason]
|
||||||
|
}
|
||||||
loginAssistanceMessage={this.props.loginAssistanceMessage}
|
loginAssistanceMessage={this.props.loginAssistanceMessage}
|
||||||
loginHelp={loginHelp}
|
loginHelp={loginHelp}
|
||||||
authProviderHint={query[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]?.toString()}
|
authProviderHint={searchParams.get(AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER) || undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,10 +5,12 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ISessionExpired } from './session_expired';
|
import type { PublicMethodsOf } from '@kbn/utility-types';
|
||||||
|
|
||||||
|
import type { SessionExpired } from './session_expired';
|
||||||
|
|
||||||
export function createSessionExpiredMock() {
|
export function createSessionExpiredMock() {
|
||||||
return {
|
return {
|
||||||
logout: jest.fn(),
|
logout: jest.fn(),
|
||||||
} as jest.Mocked<ISessionExpired>;
|
} as jest.Mocked<PublicMethodsOf<SessionExpired>>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { LogoutReason } from '../../common/types';
|
||||||
import { SessionExpired } from './session_expired';
|
import { SessionExpired } from './session_expired';
|
||||||
|
|
||||||
describe('#logout', () => {
|
describe('#logout', () => {
|
||||||
|
@ -41,7 +42,7 @@ describe('#logout', () => {
|
||||||
|
|
||||||
it(`redirects user to the logout URL with 'msg' and 'next' parameters`, async () => {
|
it(`redirects user to the logout URL with 'msg' and 'next' parameters`, async () => {
|
||||||
const sessionExpired = new SessionExpired(LOGOUT_URL, TENANT);
|
const sessionExpired = new SessionExpired(LOGOUT_URL, TENANT);
|
||||||
sessionExpired.logout();
|
sessionExpired.logout(LogoutReason.SESSION_EXPIRED);
|
||||||
|
|
||||||
const next = `&next=${encodeURIComponent(CURRENT_URL)}`;
|
const next = `&next=${encodeURIComponent(CURRENT_URL)}`;
|
||||||
await expect(window.location.assign).toHaveBeenCalledWith(
|
await expect(window.location.assign).toHaveBeenCalledWith(
|
||||||
|
@ -49,12 +50,22 @@ describe('#logout', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`redirects user to the logout URL with custom reason 'msg'`, async () => {
|
||||||
|
const sessionExpired = new SessionExpired(LOGOUT_URL, TENANT);
|
||||||
|
sessionExpired.logout(LogoutReason.AUTHENTICATION_ERROR);
|
||||||
|
|
||||||
|
const next = `&next=${encodeURIComponent(CURRENT_URL)}`;
|
||||||
|
await expect(window.location.assign).toHaveBeenCalledWith(
|
||||||
|
`${LOGOUT_URL}?msg=AUTHENTICATION_ERROR${next}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it(`adds 'provider' parameter when sessionStorage contains the provider name for this tenant`, async () => {
|
it(`adds 'provider' parameter when sessionStorage contains the provider name for this tenant`, async () => {
|
||||||
const providerName = 'basic';
|
const providerName = 'basic';
|
||||||
mockGetItem.mockReturnValueOnce(providerName);
|
mockGetItem.mockReturnValueOnce(providerName);
|
||||||
|
|
||||||
const sessionExpired = new SessionExpired(LOGOUT_URL, TENANT);
|
const sessionExpired = new SessionExpired(LOGOUT_URL, TENANT);
|
||||||
sessionExpired.logout();
|
sessionExpired.logout(LogoutReason.SESSION_EXPIRED);
|
||||||
|
|
||||||
expect(mockGetItem).toHaveBeenCalledTimes(1);
|
expect(mockGetItem).toHaveBeenCalledTimes(1);
|
||||||
expect(mockGetItem).toHaveBeenCalledWith(`${TENANT}/session_provider`);
|
expect(mockGetItem).toHaveBeenCalledWith(`${TENANT}/session_provider`);
|
||||||
|
|
|
@ -10,10 +10,7 @@ import {
|
||||||
LOGOUT_REASON_QUERY_STRING_PARAMETER,
|
LOGOUT_REASON_QUERY_STRING_PARAMETER,
|
||||||
NEXT_URL_QUERY_STRING_PARAMETER,
|
NEXT_URL_QUERY_STRING_PARAMETER,
|
||||||
} from '../../common/constants';
|
} from '../../common/constants';
|
||||||
|
import type { LogoutReason } from '../../common/types';
|
||||||
export interface ISessionExpired {
|
|
||||||
logout(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getNextParameter = () => {
|
const getNextParameter = () => {
|
||||||
const { location } = window;
|
const { location } = window;
|
||||||
|
@ -32,11 +29,11 @@ const getProviderParameter = (tenant: string) => {
|
||||||
export class SessionExpired {
|
export class SessionExpired {
|
||||||
constructor(private logoutUrl: string, private tenant: string) {}
|
constructor(private logoutUrl: string, private tenant: string) {}
|
||||||
|
|
||||||
logout() {
|
logout(reason: LogoutReason) {
|
||||||
const next = getNextParameter();
|
const next = getNextParameter();
|
||||||
const provider = getProviderParameter(this.tenant);
|
const provider = getProviderParameter(this.tenant);
|
||||||
window.location.assign(
|
window.location.assign(
|
||||||
`${this.logoutUrl}?${LOGOUT_REASON_QUERY_STRING_PARAMETER}=SESSION_EXPIRED${next}${provider}`
|
`${this.logoutUrl}?${LOGOUT_REASON_QUERY_STRING_PARAMETER}=${reason}${next}${provider}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,9 +24,10 @@ import {
|
||||||
SESSION_GRACE_PERIOD_MS,
|
SESSION_GRACE_PERIOD_MS,
|
||||||
SESSION_ROUTE,
|
SESSION_ROUTE,
|
||||||
} from '../../common/constants';
|
} from '../../common/constants';
|
||||||
|
import { LogoutReason } from '../../common/types';
|
||||||
import type { SessionInfo } from '../../common/types';
|
import type { SessionInfo } from '../../common/types';
|
||||||
import { createSessionExpirationToast } from './session_expiration_toast';
|
import { createSessionExpirationToast } from './session_expiration_toast';
|
||||||
import type { ISessionExpired } from './session_expired';
|
import type { SessionExpired } from './session_expired';
|
||||||
|
|
||||||
export interface SessionState extends Pick<SessionInfo, 'expiresInMs' | 'canBeExtended'> {
|
export interface SessionState extends Pick<SessionInfo, 'expiresInMs' | 'canBeExtended'> {
|
||||||
lastExtensionTime: number;
|
lastExtensionTime: number;
|
||||||
|
@ -58,7 +59,7 @@ export class SessionTimeout {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private notifications: NotificationsSetup,
|
private notifications: NotificationsSetup,
|
||||||
private sessionExpired: ISessionExpired,
|
private sessionExpired: Pick<SessionExpired, 'logout'>,
|
||||||
private http: HttpSetup,
|
private http: HttpSetup,
|
||||||
private tenant: string
|
private tenant: string
|
||||||
) {}
|
) {}
|
||||||
|
@ -168,7 +169,10 @@ export class SessionTimeout {
|
||||||
const fetchSessionInMs = showWarningInMs - SESSION_CHECK_MS;
|
const fetchSessionInMs = showWarningInMs - SESSION_CHECK_MS;
|
||||||
|
|
||||||
// Schedule logout when session is about to expire
|
// Schedule logout when session is about to expire
|
||||||
this.stopLogoutTimer = startTimer(() => this.sessionExpired.logout(), logoutInMs);
|
this.stopLogoutTimer = startTimer(
|
||||||
|
() => this.sessionExpired.logout(LogoutReason.SESSION_EXPIRED),
|
||||||
|
logoutInMs
|
||||||
|
);
|
||||||
|
|
||||||
// Hide warning if session has been extended
|
// Hide warning if session has been extended
|
||||||
if (showWarningInMs > 0) {
|
if (showWarningInMs > 0) {
|
||||||
|
|
|
@ -56,6 +56,7 @@ it(`logs out 401 responses`, async () => {
|
||||||
await drainPromiseQueue();
|
await drainPromiseQueue();
|
||||||
expect(fetchResolved).toBe(false);
|
expect(fetchResolved).toBe(false);
|
||||||
expect(fetchRejected).toBe(false);
|
expect(fetchRejected).toBe(false);
|
||||||
|
expect(sessionExpired.logout).toHaveBeenCalledWith('AUTHENTICATION_ERROR');
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`ignores anonymous paths`, async () => {
|
it(`ignores anonymous paths`, async () => {
|
||||||
|
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
IHttpInterceptController,
|
IHttpInterceptController,
|
||||||
} from 'src/core/public';
|
} from 'src/core/public';
|
||||||
|
|
||||||
|
import { LogoutReason } from '../../common/types';
|
||||||
import type { SessionExpired } from './session_expired';
|
import type { SessionExpired } from './session_expired';
|
||||||
|
|
||||||
export class UnauthorizedResponseHttpInterceptor implements HttpInterceptor {
|
export class UnauthorizedResponseHttpInterceptor implements HttpInterceptor {
|
||||||
|
@ -39,7 +40,7 @@ export class UnauthorizedResponseHttpInterceptor implements HttpInterceptor {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
this.sessionExpired.logout();
|
this.sessionExpired.logout(LogoutReason.AUTHENTICATION_ERROR);
|
||||||
controller.halt();
|
controller.halt();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue