Implement AnonymousAuthenticationProvider. (#79985)
This commit is contained in:
parent
197b9dd582
commit
e3ca8a928d
|
@ -191,6 +191,18 @@ export async function BrowserProvider({ getService }: FtrProviderContext) {
|
|||
return await driver.get(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the cookie with the given name. Returns null if there is no such cookie. The cookie will be returned as
|
||||
* a JSON object as described by the WebDriver wire protocol.
|
||||
* https://www.selenium.dev/selenium/docs/api/javascript/module/selenium-webdriver/lib/webdriver_exports_Options.html
|
||||
*
|
||||
* @param {string} cookieName
|
||||
* @return {Promise<IWebDriverCookie>}
|
||||
*/
|
||||
public async getCookie(cookieName: string) {
|
||||
return await driver.manage().getCookie(cookieName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the execution in the browser, similar to setting a breakpoint for debugging.
|
||||
* @return {Promise<void>}
|
||||
|
|
|
@ -17,3 +17,5 @@ export const UNKNOWN_SPACE = '?';
|
|||
export const GLOBAL_RESOURCE = '*';
|
||||
export const APPLICATION_PREFIX = 'kibana-';
|
||||
export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*';
|
||||
|
||||
export const AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER = 'auth_provider_hint';
|
||||
|
|
|
@ -10,6 +10,7 @@ export interface LoginSelectorProvider {
|
|||
type: string;
|
||||
name: string;
|
||||
usesLoginForm: boolean;
|
||||
showInSelector: boolean;
|
||||
description?: string;
|
||||
hint?: string;
|
||||
icon?: string;
|
||||
|
|
|
@ -20,6 +20,19 @@ describe('#canUserChangePassword', () => {
|
|||
} as AuthenticatedUser)
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
it(`returns false for users in the ${realm} realm if used for anonymous access`, () => {
|
||||
expect(
|
||||
canUserChangePassword({
|
||||
username: 'foo',
|
||||
authentication_provider: { type: 'anonymous', name: 'does not matter' },
|
||||
authentication_realm: {
|
||||
name: 'the realm name',
|
||||
type: realm,
|
||||
},
|
||||
} as AuthenticatedUser)
|
||||
).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
it(`returns false for all other realms`, () => {
|
||||
|
|
|
@ -42,5 +42,8 @@ export interface AuthenticatedUser extends User {
|
|||
}
|
||||
|
||||
export function canUserChangePassword(user: AuthenticatedUser) {
|
||||
return REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(user.authentication_realm.type);
|
||||
return (
|
||||
REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(user.authentication_realm.type) &&
|
||||
user.authentication_provider.type !== 'anonymous'
|
||||
);
|
||||
}
|
||||
|
|
|
@ -133,45 +133,6 @@ exports[`LoginPage enabled form state renders as expected 1`] = `
|
|||
/>
|
||||
`;
|
||||
|
||||
exports[`LoginPage enabled form state renders as expected when info message is set 1`] = `
|
||||
<LoginForm
|
||||
http={
|
||||
Object {
|
||||
"addLoadingCountSource": [MockFunction],
|
||||
"get": [MockFunction],
|
||||
}
|
||||
}
|
||||
infoMessage="Your session has timed out. Please log in again."
|
||||
loginAssistanceMessage=""
|
||||
notifications={
|
||||
Object {
|
||||
"toasts": Object {
|
||||
"add": [MockFunction],
|
||||
"addDanger": [MockFunction],
|
||||
"addError": [MockFunction],
|
||||
"addInfo": [MockFunction],
|
||||
"addSuccess": [MockFunction],
|
||||
"addWarning": [MockFunction],
|
||||
"get$": [MockFunction],
|
||||
"remove": [MockFunction],
|
||||
},
|
||||
}
|
||||
}
|
||||
selector={
|
||||
Object {
|
||||
"enabled": false,
|
||||
"providers": Array [
|
||||
Object {
|
||||
"name": "basic1",
|
||||
"type": "basic",
|
||||
"usesLoginForm": true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`LoginPage enabled form state renders as expected when loginAssistanceMessage is set 1`] = `
|
||||
<LoginForm
|
||||
http={
|
||||
|
@ -180,7 +141,6 @@ exports[`LoginPage enabled form state renders as expected when loginAssistanceMe
|
|||
"get": [MockFunction],
|
||||
}
|
||||
}
|
||||
infoMessage="Your session has timed out. Please log in again."
|
||||
loginAssistanceMessage="This is an *important* message"
|
||||
notifications={
|
||||
Object {
|
||||
|
@ -219,7 +179,6 @@ exports[`LoginPage enabled form state renders as expected when loginHelp is set
|
|||
"get": [MockFunction],
|
||||
}
|
||||
}
|
||||
infoMessage="Your session has timed out. Please log in again."
|
||||
loginAssistanceMessage=""
|
||||
loginHelp="**some-help**"
|
||||
notifications={
|
||||
|
|
|
@ -22,23 +22,40 @@ function expectPageMode(wrapper: ReactWrapper, mode: PageMode) {
|
|||
['loginForm', true],
|
||||
['loginSelector', false],
|
||||
['loginHelp', false],
|
||||
['autoLoginOverlay', false],
|
||||
]
|
||||
: mode === PageMode.Selector
|
||||
? [
|
||||
['loginForm', false],
|
||||
['loginSelector', true],
|
||||
['loginHelp', false],
|
||||
['autoLoginOverlay', false],
|
||||
]
|
||||
: [
|
||||
['loginForm', false],
|
||||
['loginSelector', false],
|
||||
['loginHelp', true],
|
||||
['autoLoginOverlay', false],
|
||||
];
|
||||
for (const [selector, exists] of assertions) {
|
||||
expect(findTestSubject(wrapper, selector).exists()).toBe(exists);
|
||||
}
|
||||
}
|
||||
|
||||
function expectAutoLoginOverlay(wrapper: ReactWrapper) {
|
||||
// Everything should be hidden except for the overlay
|
||||
for (const selector of [
|
||||
'loginForm',
|
||||
'loginSelector',
|
||||
'loginHelp',
|
||||
'loginHelpLink',
|
||||
'loginAssistanceMessage',
|
||||
]) {
|
||||
expect(findTestSubject(wrapper, selector).exists()).toBe(false);
|
||||
}
|
||||
expect(findTestSubject(wrapper, 'autoLoginOverlay').exists()).toBe(true);
|
||||
}
|
||||
|
||||
describe('LoginForm', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
|
@ -57,7 +74,9 @@ describe('LoginForm', () => {
|
|||
loginAssistanceMessage=""
|
||||
selector={{
|
||||
enabled: false,
|
||||
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
|
||||
providers: [
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
@ -74,7 +93,7 @@ describe('LoginForm', () => {
|
|||
loginAssistanceMessage=""
|
||||
selector={{
|
||||
enabled: false,
|
||||
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
|
||||
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -94,7 +113,7 @@ describe('LoginForm', () => {
|
|||
loginAssistanceMessage=""
|
||||
selector={{
|
||||
enabled: false,
|
||||
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
|
||||
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -115,7 +134,7 @@ describe('LoginForm', () => {
|
|||
loginAssistanceMessage=""
|
||||
selector={{
|
||||
enabled: false,
|
||||
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
|
||||
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -147,7 +166,7 @@ describe('LoginForm', () => {
|
|||
loginAssistanceMessage=""
|
||||
selector={{
|
||||
enabled: false,
|
||||
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
|
||||
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -180,7 +199,7 @@ describe('LoginForm', () => {
|
|||
loginAssistanceMessage=""
|
||||
selector={{
|
||||
enabled: false,
|
||||
providers: [{ type: 'basic', name: 'basic1', usesLoginForm: true }],
|
||||
providers: [{ type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -222,7 +241,7 @@ describe('LoginForm', () => {
|
|||
loginHelp="**some help**"
|
||||
selector={{
|
||||
enabled: false,
|
||||
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
|
||||
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -261,14 +280,22 @@ describe('LoginForm', () => {
|
|||
usesLoginForm: true,
|
||||
hint: 'Basic hint',
|
||||
icon: 'logoElastic',
|
||||
showInSelector: true,
|
||||
},
|
||||
{
|
||||
type: 'saml',
|
||||
name: 'saml1',
|
||||
description: 'Log in w/SAML',
|
||||
usesLoginForm: false,
|
||||
showInSelector: true,
|
||||
},
|
||||
{ type: 'saml', name: 'saml1', description: 'Log in w/SAML', usesLoginForm: false },
|
||||
{
|
||||
type: 'pki',
|
||||
name: 'pki1',
|
||||
description: 'Log in w/PKI',
|
||||
hint: 'PKI hint',
|
||||
usesLoginForm: false,
|
||||
showInSelector: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
@ -309,8 +336,15 @@ describe('LoginForm', () => {
|
|||
description: 'Login w/SAML',
|
||||
hint: 'SAML hint',
|
||||
usesLoginForm: false,
|
||||
showInSelector: true,
|
||||
},
|
||||
{
|
||||
type: 'pki',
|
||||
name: 'pki1',
|
||||
icon: 'some-icon',
|
||||
usesLoginForm: false,
|
||||
showInSelector: true,
|
||||
},
|
||||
{ type: 'pki', name: 'pki1', icon: 'some-icon', usesLoginForm: false },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
@ -352,9 +386,21 @@ describe('LoginForm', () => {
|
|||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true },
|
||||
{ type: 'saml', name: 'saml1', description: 'Login w/SAML', usesLoginForm: false },
|
||||
{ type: 'pki', name: 'pki1', description: 'Login w/PKI', usesLoginForm: false },
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true },
|
||||
{
|
||||
type: 'saml',
|
||||
name: 'saml1',
|
||||
description: 'Login w/SAML',
|
||||
usesLoginForm: false,
|
||||
showInSelector: true,
|
||||
},
|
||||
{
|
||||
type: 'pki',
|
||||
name: 'pki1',
|
||||
description: 'Login w/PKI',
|
||||
usesLoginForm: false,
|
||||
showInSelector: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
@ -397,8 +443,8 @@ describe('LoginForm', () => {
|
|||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false },
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
@ -445,8 +491,8 @@ describe('LoginForm', () => {
|
|||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false },
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
@ -488,8 +534,8 @@ describe('LoginForm', () => {
|
|||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false },
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
@ -517,8 +563,8 @@ describe('LoginForm', () => {
|
|||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false },
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
@ -554,8 +600,8 @@ describe('LoginForm', () => {
|
|||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false },
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true, showInSelector: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
@ -591,4 +637,168 @@ describe('LoginForm', () => {
|
|||
expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto login', () => {
|
||||
it('automatically switches to the Login Form mode if provider suggested by the auth provider hint needs it', () => {
|
||||
const coreStartMock = coreMock.createStart();
|
||||
const wrapper = mountWithIntl(
|
||||
<LoginForm
|
||||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
loginHelp={'**Hey this is a login help message**'}
|
||||
loginAssistanceMessage="Need assistance?"
|
||||
authProviderHint="basic1"
|
||||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expectPageMode(wrapper, PageMode.Form);
|
||||
expect(findTestSubject(wrapper, 'loginHelpLink').text()).toEqual('Need help?');
|
||||
expect(findTestSubject(wrapper, 'loginAssistanceMessage').text()).toEqual('Need assistance?');
|
||||
});
|
||||
|
||||
it('automatically logs in if provider suggested by the auth provider hint is displayed in the selector', async () => {
|
||||
const currentURL = `https://some-host/login?next=${encodeURIComponent(
|
||||
'/some-base-path/app/kibana#/home?_g=()'
|
||||
)}`;
|
||||
const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' });
|
||||
coreStartMock.http.post.mockResolvedValue({
|
||||
location: 'https://external-idp/login?optional-arg=2#optional-hash',
|
||||
});
|
||||
|
||||
window.location.href = currentURL;
|
||||
const wrapper = mountWithIntl(
|
||||
<LoginForm
|
||||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
loginHelp={'**Hey this is a login help message**'}
|
||||
loginAssistanceMessage="Need assistance?"
|
||||
authProviderHint="saml1"
|
||||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expectAutoLoginOverlay(wrapper);
|
||||
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(coreStartMock.http.post).toHaveBeenCalledTimes(1);
|
||||
expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', {
|
||||
body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }),
|
||||
});
|
||||
|
||||
expect(window.location.href).toBe('https://external-idp/login?optional-arg=2#optional-hash');
|
||||
expect(wrapper.find(EuiCallOut).exists()).toBe(false);
|
||||
expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('automatically logs in if provider suggested by the auth provider hint is not displayed in the selector', async () => {
|
||||
const currentURL = `https://some-host/login?next=${encodeURIComponent(
|
||||
'/some-base-path/app/kibana#/home?_g=()'
|
||||
)}`;
|
||||
const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' });
|
||||
coreStartMock.http.post.mockResolvedValue({
|
||||
location: 'https://external-idp/login?optional-arg=2#optional-hash',
|
||||
});
|
||||
|
||||
window.location.href = currentURL;
|
||||
const wrapper = mountWithIntl(
|
||||
<LoginForm
|
||||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
loginHelp={'**Hey this is a login help message**'}
|
||||
loginAssistanceMessage="Need assistance?"
|
||||
authProviderHint="saml1"
|
||||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: false },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expectAutoLoginOverlay(wrapper);
|
||||
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(coreStartMock.http.post).toHaveBeenCalledTimes(1);
|
||||
expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', {
|
||||
body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }),
|
||||
});
|
||||
|
||||
expect(window.location.href).toBe('https://external-idp/login?optional-arg=2#optional-hash');
|
||||
expect(wrapper.find(EuiCallOut).exists()).toBe(false);
|
||||
expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches to the login selector if could not login with provider suggested by the auth provider hint', async () => {
|
||||
const currentURL = `https://some-host/login?next=${encodeURIComponent(
|
||||
'/some-base-path/app/kibana#/home?_g=()'
|
||||
)}`;
|
||||
|
||||
const failureReason = new Error('Oh no!');
|
||||
const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' });
|
||||
coreStartMock.http.post.mockRejectedValue(failureReason);
|
||||
|
||||
window.location.href = currentURL;
|
||||
const wrapper = mountWithIntl(
|
||||
<LoginForm
|
||||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
loginHelp={'**Hey this is a login help message**'}
|
||||
loginAssistanceMessage="Need assistance?"
|
||||
authProviderHint="saml1"
|
||||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true },
|
||||
{ type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expectAutoLoginOverlay(wrapper);
|
||||
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
expect(coreStartMock.http.post).toHaveBeenCalledTimes(1);
|
||||
expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', {
|
||||
body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }),
|
||||
});
|
||||
|
||||
expect(window.location.href).toBe(currentURL);
|
||||
expect(coreStartMock.notifications.toasts.addError).toHaveBeenCalledWith(failureReason, {
|
||||
title: 'Could not perform login.',
|
||||
toastMessage: 'Oh no!',
|
||||
});
|
||||
|
||||
expectPageMode(wrapper, PageMode.Selector);
|
||||
expect(findTestSubject(wrapper, 'loginHelpLink').text()).toEqual('Need help?');
|
||||
expect(findTestSubject(wrapper, 'loginAssistanceMessage').text()).toEqual('Need assistance?');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -29,7 +29,7 @@ import {
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public';
|
||||
import { LoginSelector } from '../../../../../common/login_state';
|
||||
import type { LoginSelector, LoginSelectorProvider } from '../../../../../common/login_state';
|
||||
import { LoginValidator } from './validate_login';
|
||||
|
||||
interface Props {
|
||||
|
@ -39,12 +39,12 @@ interface Props {
|
|||
infoMessage?: string;
|
||||
loginAssistanceMessage: string;
|
||||
loginHelp?: string;
|
||||
authProviderHint?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
loadingState:
|
||||
| { type: LoadingStateType.None }
|
||||
| { type: LoadingStateType.Form }
|
||||
| { type: LoadingStateType.None | LoadingStateType.Form | LoadingStateType.AutoLogin }
|
||||
| { type: LoadingStateType.Selector; providerName: string };
|
||||
username: string;
|
||||
password: string;
|
||||
|
@ -59,6 +59,7 @@ enum LoadingStateType {
|
|||
None,
|
||||
Form,
|
||||
Selector,
|
||||
AutoLogin,
|
||||
}
|
||||
|
||||
enum MessageType {
|
||||
|
@ -76,11 +77,26 @@ export enum PageMode {
|
|||
export class LoginForm extends Component<Props, State> {
|
||||
private readonly validator: LoginValidator;
|
||||
|
||||
/**
|
||||
* Optional provider that was suggested by the `auth_provider_hint={providerName}` query string parameter. If provider
|
||||
* doesn't require Kibana native login form then login process is triggered automatically, otherwise Login Selector
|
||||
* just switches to the Login Form mode.
|
||||
*/
|
||||
private readonly suggestedProvider?: LoginSelectorProvider;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.validator = new LoginValidator({ shouldValidate: false });
|
||||
|
||||
const mode = this.showLoginSelector() ? PageMode.Selector : PageMode.Form;
|
||||
this.suggestedProvider = this.props.authProviderHint
|
||||
? this.props.selector.providers.find(({ name }) => name === this.props.authProviderHint)
|
||||
: undefined;
|
||||
|
||||
// Switch to the Form mode right away if provider from the hint requires it.
|
||||
const mode =
|
||||
this.showLoginSelector() && !this.suggestedProvider?.usesLoginForm
|
||||
? PageMode.Selector
|
||||
: PageMode.Form;
|
||||
|
||||
this.state = {
|
||||
loadingState: { type: LoadingStateType.None },
|
||||
|
@ -94,7 +110,17 @@ export class LoginForm extends Component<Props, State> {
|
|||
};
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
if (this.suggestedProvider?.usesLoginForm === false) {
|
||||
await this.loginWithSelector({ provider: this.suggestedProvider, autoLogin: true });
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.isLoadingState(LoadingStateType.AutoLogin)) {
|
||||
return this.renderAutoLoginOverlay();
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{this.renderLoginAssistanceMessage()}
|
||||
|
@ -111,7 +137,7 @@ export class LoginForm extends Component<Props, State> {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="secLoginAssistanceMessage">
|
||||
<div data-test-subj="loginAssistanceMessage" className="secLoginAssistanceMessage">
|
||||
<EuiHorizontalRule size="half" />
|
||||
<EuiText size="xs">
|
||||
<ReactMarkdown>{this.props.loginAssistanceMessage}</ReactMarkdown>
|
||||
|
@ -257,9 +283,10 @@ export class LoginForm extends Component<Props, State> {
|
|||
};
|
||||
|
||||
private renderSelector = () => {
|
||||
const providers = this.props.selector.providers.filter((provider) => provider.showInSelector);
|
||||
return (
|
||||
<EuiPanel data-test-subj="loginSelector" paddingSize="none">
|
||||
{this.props.selector.providers.map((provider) => (
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
key={provider.name}
|
||||
data-test-subj={`loginCard-${provider.type}/${provider.name}`}
|
||||
|
@ -267,7 +294,7 @@ export class LoginForm extends Component<Props, State> {
|
|||
onClick={() =>
|
||||
provider.usesLoginForm
|
||||
? this.onPageModeChange(PageMode.Form)
|
||||
: this.loginWithSelector(provider.type, provider.name)
|
||||
: this.loginWithSelector({ provider })
|
||||
}
|
||||
className={`secLoginCard ${
|
||||
this.isLoadingState(LoadingStateType.Selector, provider.name)
|
||||
|
@ -360,6 +387,30 @@ export class LoginForm extends Component<Props, State> {
|
|||
return null;
|
||||
};
|
||||
|
||||
private renderAutoLoginOverlay = () => {
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
data-test-subj="autoLoginOverlay"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gutterSize="m"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="l" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="m" className="eui-textCenter">
|
||||
<FormattedMessage
|
||||
id="xpack.security.loginPage.autoLoginAuthenticatingLabel"
|
||||
defaultMessage="Authenticating…"
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
private setUsernameInputRef(ref: HTMLInputElement) {
|
||||
if (ref) {
|
||||
ref.focus();
|
||||
|
@ -438,9 +489,17 @@ export class LoginForm extends Component<Props, State> {
|
|||
}
|
||||
};
|
||||
|
||||
private loginWithSelector = async (providerType: string, providerName: string) => {
|
||||
private loginWithSelector = async ({
|
||||
provider: { type: providerType, name: providerName },
|
||||
autoLogin,
|
||||
}: {
|
||||
provider: LoginSelectorProvider;
|
||||
autoLogin?: boolean;
|
||||
}) => {
|
||||
this.setState({
|
||||
loadingState: { type: LoadingStateType.Selector, providerName },
|
||||
loadingState: autoLogin
|
||||
? { type: LoadingStateType.AutoLogin }
|
||||
: { type: LoadingStateType.Selector, providerName },
|
||||
message: { type: MessageType.None },
|
||||
});
|
||||
|
||||
|
@ -466,7 +525,9 @@ export class LoginForm extends Component<Props, State> {
|
|||
}
|
||||
};
|
||||
|
||||
private isLoadingState(type: LoadingStateType.None | LoadingStateType.Form): boolean;
|
||||
private isLoadingState(
|
||||
type: LoadingStateType.None | LoadingStateType.Form | LoadingStateType.AutoLogin
|
||||
): boolean;
|
||||
private isLoadingState(type: LoadingStateType.Selector, providerName: string): boolean;
|
||||
private isLoadingState(type: LoadingStateType, providerName?: string) {
|
||||
const { loadingState } = this.state;
|
||||
|
@ -482,7 +543,9 @@ export class LoginForm extends Component<Props, State> {
|
|||
private showLoginSelector() {
|
||||
return (
|
||||
this.props.selector.enabled &&
|
||||
this.props.selector.providers.some((provider) => !provider.usesLoginForm)
|
||||
this.props.selector.providers.some(
|
||||
(provider) => !provider.usesLoginForm && provider.showInSelector
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import React from 'react';
|
|||
import { shallow } from 'enzyme';
|
||||
import { act } from '@testing-library/react';
|
||||
import { nextTick } from '@kbn/test/jest';
|
||||
import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants';
|
||||
import { LoginState } from '../../../common/login_state';
|
||||
import { LoginPage } from './login_page';
|
||||
import { coreMock } from '../../../../../../src/core/public/mocks';
|
||||
|
@ -37,14 +38,12 @@ describe('LoginPage', () => {
|
|||
httpMock.addLoadingCountSource.mockReset();
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: 'http://some-host/bar', protocol: 'http' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetHttpMock();
|
||||
});
|
||||
|
||||
|
@ -206,10 +205,10 @@ describe('LoginPage', () => {
|
|||
expect(wrapper.find(LoginForm)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders as expected when info message is set', async () => {
|
||||
it('properly passes query string parameters to the form', async () => {
|
||||
const coreStartMock = coreMock.createStart();
|
||||
httpMock.get.mockResolvedValue(createLoginState());
|
||||
window.location.href = 'http://some-host/bar?msg=SESSION_EXPIRED';
|
||||
window.location.href = `http://some-host/bar?msg=SESSION_EXPIRED&${AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER}=basic1`;
|
||||
|
||||
const wrapper = shallow(
|
||||
<LoginPage
|
||||
|
@ -226,7 +225,9 @@ describe('LoginPage', () => {
|
|||
resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot
|
||||
});
|
||||
|
||||
expect(wrapper.find(LoginForm)).toMatchSnapshot();
|
||||
const { authProviderHint, infoMessage } = wrapper.find(LoginForm).props();
|
||||
expect(authProviderHint).toBe('basic1');
|
||||
expect(infoMessage).toBe('Your session has timed out. Please log in again.');
|
||||
});
|
||||
|
||||
it('renders as expected when loginAssistanceMessage is set', async () => {
|
||||
|
|
|
@ -15,6 +15,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiTitle } from '@elasti
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { CoreStart, FatalErrorsStart, HttpStart, NotificationsStart } from 'src/core/public';
|
||||
import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants';
|
||||
import { LoginState } from '../../../common/login_state';
|
||||
import { LoginForm, DisabledLoginForm } from './components';
|
||||
|
||||
|
@ -212,14 +213,16 @@ export class LoginPage extends Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
const query = parse(window.location.href, true).query;
|
||||
return (
|
||||
<LoginForm
|
||||
http={this.props.http}
|
||||
notifications={this.props.notifications}
|
||||
selector={selector}
|
||||
infoMessage={infoMessageMap.get(parse(window.location.href, true).query.msg?.toString())}
|
||||
infoMessage={infoMessageMap.get(query.msg?.toString())}
|
||||
loginAssistanceMessage={this.props.loginAssistanceMessage}
|
||||
loginHelp={loginHelp}
|
||||
authProviderHint={query[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]?.toString()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -8,14 +8,16 @@ import React from 'react';
|
|||
import { BehaviorSubject } from 'rxjs';
|
||||
import { shallowWithIntl, nextTick, mountWithIntl } from '@kbn/test/jest';
|
||||
import { SecurityNavControl } from './nav_control_component';
|
||||
import { AuthenticatedUser } from '../../common/model';
|
||||
import type { AuthenticatedUser } from '../../common/model';
|
||||
import { EuiPopover, EuiHeaderSectionItemButton } from '@elastic/eui';
|
||||
import { findTestSubject } from '@kbn/test/jest';
|
||||
|
||||
import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock';
|
||||
|
||||
describe('SecurityNavControl', () => {
|
||||
it(`renders a loading spinner when the user promise hasn't resolved yet.`, async () => {
|
||||
const props = {
|
||||
user: new Promise(() => {}) as Promise<AuthenticatedUser>,
|
||||
user: new Promise<AuthenticatedUser>(() => mockAuthenticatedUser()),
|
||||
editProfileUrl: '',
|
||||
logoutUrl: '',
|
||||
userMenuLinks$: new BehaviorSubject([]),
|
||||
|
@ -41,7 +43,7 @@ describe('SecurityNavControl', () => {
|
|||
|
||||
it(`renders an avatar after the user promise resolves.`, async () => {
|
||||
const props = {
|
||||
user: Promise.resolve({ full_name: 'foo' }) as Promise<AuthenticatedUser>,
|
||||
user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })),
|
||||
editProfileUrl: '',
|
||||
logoutUrl: '',
|
||||
userMenuLinks$: new BehaviorSubject([]),
|
||||
|
@ -70,7 +72,7 @@ describe('SecurityNavControl', () => {
|
|||
|
||||
it(`doesn't render the popover when the user hasn't been loaded yet`, async () => {
|
||||
const props = {
|
||||
user: Promise.resolve({ full_name: 'foo' }) as Promise<AuthenticatedUser>,
|
||||
user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })),
|
||||
editProfileUrl: '',
|
||||
logoutUrl: '',
|
||||
userMenuLinks$: new BehaviorSubject([]),
|
||||
|
@ -92,7 +94,7 @@ describe('SecurityNavControl', () => {
|
|||
|
||||
it('renders a popover when the avatar is clicked.', async () => {
|
||||
const props = {
|
||||
user: Promise.resolve({ full_name: 'foo' }) as Promise<AuthenticatedUser>,
|
||||
user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })),
|
||||
editProfileUrl: '',
|
||||
logoutUrl: '',
|
||||
userMenuLinks$: new BehaviorSubject([]),
|
||||
|
@ -115,7 +117,7 @@ describe('SecurityNavControl', () => {
|
|||
|
||||
it('renders a popover with additional user menu links registered by other plugins', async () => {
|
||||
const props = {
|
||||
user: Promise.resolve({ full_name: 'foo' }) as Promise<AuthenticatedUser>,
|
||||
user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })),
|
||||
editProfileUrl: '',
|
||||
logoutUrl: '',
|
||||
userMenuLinks$: new BehaviorSubject([
|
||||
|
@ -145,4 +147,37 @@ describe('SecurityNavControl', () => {
|
|||
expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(1);
|
||||
expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('properly renders a popover for anonymous user.', async () => {
|
||||
const props = {
|
||||
user: Promise.resolve(
|
||||
mockAuthenticatedUser({
|
||||
authentication_provider: { type: 'anonymous', name: 'does no matter' },
|
||||
})
|
||||
),
|
||||
editProfileUrl: '',
|
||||
logoutUrl: '',
|
||||
userMenuLinks$: new BehaviorSubject([
|
||||
{ label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 },
|
||||
{ label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 },
|
||||
{ label: 'link3', href: 'path-to-link-3', iconType: 'empty', order: 3 },
|
||||
]),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<SecurityNavControl {...props} />);
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
|
||||
expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0);
|
||||
expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0);
|
||||
expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0);
|
||||
|
||||
wrapper.find(EuiHeaderSectionItemButton).simulate('click');
|
||||
|
||||
expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1);
|
||||
expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0);
|
||||
expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1);
|
||||
|
||||
expect(findTestSubject(wrapper, 'logoutLink').text()).toBe('Log in');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -118,33 +118,23 @@ export class SecurityNavControl extends Component<Props, State> {
|
|||
</EuiHeaderSectionItemButton>
|
||||
);
|
||||
|
||||
const profileMenuItem = {
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.security.navControlComponent.editProfileLinkText"
|
||||
defaultMessage="Profile"
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="user" size="m" />,
|
||||
href: editProfileUrl,
|
||||
'data-test-subj': 'profileLink',
|
||||
};
|
||||
|
||||
const logoutMenuItem = {
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.security.navControlComponent.logoutLinkText"
|
||||
defaultMessage="Log out"
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="exit" size="m" />,
|
||||
href: logoutUrl,
|
||||
'data-test-subj': 'logoutLink',
|
||||
};
|
||||
|
||||
const isAnonymousUser = authenticatedUser?.authentication_provider.type === 'anonymous';
|
||||
const items: EuiContextMenuPanelItemDescriptor[] = [];
|
||||
|
||||
items.push(profileMenuItem);
|
||||
if (!isAnonymousUser) {
|
||||
const profileMenuItem = {
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.security.navControlComponent.editProfileLinkText"
|
||||
defaultMessage="Profile"
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="user" size="m" />,
|
||||
href: editProfileUrl,
|
||||
'data-test-subj': 'profileLink',
|
||||
};
|
||||
items.push(profileMenuItem);
|
||||
}
|
||||
|
||||
if (userMenuLinks.length) {
|
||||
const userMenuLinkMenuItems = userMenuLinks
|
||||
|
@ -162,6 +152,22 @@ export class SecurityNavControl extends Component<Props, State> {
|
|||
});
|
||||
}
|
||||
|
||||
const logoutMenuItem = {
|
||||
name: isAnonymousUser ? (
|
||||
<FormattedMessage
|
||||
id="xpack.security.navControlComponent.loginLinkText"
|
||||
defaultMessage="Log in"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.security.navControlComponent.logoutLinkText"
|
||||
defaultMessage="Log out"
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="exit" size="m" />,
|
||||
href: logoutUrl,
|
||||
'data-test-subj': 'logoutLink',
|
||||
};
|
||||
items.push(logoutMenuItem);
|
||||
|
||||
const panels = [
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
ILegacyClusterClient,
|
||||
IBasePath,
|
||||
} from '../../../../../src/core/server';
|
||||
import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../common/constants';
|
||||
import type { SecurityLicense } from '../../common/licensing';
|
||||
import type { AuthenticatedUser } from '../../common/model';
|
||||
import type { AuthenticationProvider } from '../../common/types';
|
||||
|
@ -20,6 +21,7 @@ import type { SecurityFeatureUsageServiceStart } from '../feature_usage';
|
|||
import type { SessionValue, Session } from '../session_management';
|
||||
|
||||
import {
|
||||
AnonymousAuthenticationProvider,
|
||||
AuthenticationProviderOptions,
|
||||
AuthenticationProviderSpecificOptions,
|
||||
BaseAuthenticationProvider,
|
||||
|
@ -86,6 +88,7 @@ const providerMap = new Map<
|
|||
[TokenAuthenticationProvider.type, TokenAuthenticationProvider],
|
||||
[OIDCAuthenticationProvider.type, OIDCAuthenticationProvider],
|
||||
[PKIAuthenticationProvider.type, PKIAuthenticationProvider],
|
||||
[AnonymousAuthenticationProvider.type, AnonymousAuthenticationProvider],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
@ -328,19 +331,26 @@ export class Authenticator {
|
|||
assertRequest(request);
|
||||
|
||||
const existingSessionValue = await this.getSessionValue(request);
|
||||
const suggestedProviderName =
|
||||
existingSessionValue?.provider.name ??
|
||||
request.url.searchParams.get(AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER);
|
||||
|
||||
if (this.shouldRedirectToLoginSelector(request, existingSessionValue)) {
|
||||
this.logger.debug('Redirecting request to Login Selector.');
|
||||
return AuthenticationResult.redirectTo(
|
||||
`${this.options.basePath.serverBasePath}/login?next=${encodeURIComponent(
|
||||
`${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`
|
||||
)}`
|
||||
)}${
|
||||
suggestedProviderName && !existingSessionValue
|
||||
? `&${AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER}=${encodeURIComponent(
|
||||
suggestedProviderName
|
||||
)}`
|
||||
: ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const [providerName, provider] of this.providerIterator(
|
||||
existingSessionValue?.provider.name
|
||||
)) {
|
||||
for (const [providerName, provider] of this.providerIterator(suggestedProviderName)) {
|
||||
// Check if current session has been set by this provider.
|
||||
const ownsSession =
|
||||
existingSessionValue?.provider.name === providerName &&
|
||||
|
|
|
@ -0,0 +1,246 @@
|
|||
/*
|
||||
* 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 { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks';
|
||||
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
|
||||
import { mockAuthenticationProviderOptions } from './base.mock';
|
||||
|
||||
import { ILegacyClusterClient, ScopeableRequest } from '../../../../../../src/core/server';
|
||||
import { AuthenticationResult } from '../authentication_result';
|
||||
import { DeauthenticationResult } from '../deauthentication_result';
|
||||
import {
|
||||
BasicHTTPAuthorizationHeaderCredentials,
|
||||
HTTPAuthorizationHeader,
|
||||
} from '../http_authentication';
|
||||
import { AnonymousAuthenticationProvider } from './anonymous';
|
||||
|
||||
function expectAuthenticateCall(
|
||||
mockClusterClient: jest.Mocked<ILegacyClusterClient>,
|
||||
scopeableRequest: ScopeableRequest
|
||||
) {
|
||||
expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1);
|
||||
expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest);
|
||||
|
||||
const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value;
|
||||
expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1);
|
||||
expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate');
|
||||
}
|
||||
|
||||
describe('AnonymousAuthenticationProvider', () => {
|
||||
const user = mockAuthenticatedUser({
|
||||
authentication_provider: { type: 'anonymous', name: 'anonymous1' },
|
||||
});
|
||||
|
||||
for (const useBasicCredentials of [true, false]) {
|
||||
describe(`with ${useBasicCredentials ? '`Basic`' : '`ApiKey`'} credentials`, () => {
|
||||
let provider: AnonymousAuthenticationProvider;
|
||||
let mockOptions: ReturnType<typeof mockAuthenticationProviderOptions>;
|
||||
let authorization: string;
|
||||
beforeEach(() => {
|
||||
mockOptions = mockAuthenticationProviderOptions({ name: 'anonymous1' });
|
||||
|
||||
provider = useBasicCredentials
|
||||
? new AnonymousAuthenticationProvider(mockOptions, {
|
||||
credentials: { username: 'user', password: 'pass' },
|
||||
})
|
||||
: new AnonymousAuthenticationProvider(mockOptions, {
|
||||
credentials: { apiKey: 'some-apiKey' },
|
||||
});
|
||||
authorization = useBasicCredentials
|
||||
? new HTTPAuthorizationHeader(
|
||||
'Basic',
|
||||
new BasicHTTPAuthorizationHeaderCredentials('user', 'pass').toString()
|
||||
).toString()
|
||||
: new HTTPAuthorizationHeader('ApiKey', 'some-apiKey').toString();
|
||||
});
|
||||
|
||||
describe('`login` method', () => {
|
||||
it('succeeds if credentials are valid, and creates session and authHeaders', async () => {
|
||||
const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
|
||||
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user);
|
||||
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
|
||||
|
||||
await expect(
|
||||
provider.login(httpServerMock.createKibanaRequest({ headers: {} }))
|
||||
).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(user, {
|
||||
authHeaders: { authorization },
|
||||
state: {},
|
||||
})
|
||||
);
|
||||
expectAuthenticateCall(mockOptions.client, { headers: { authorization } });
|
||||
});
|
||||
|
||||
it('fails if user cannot be retrieved during login attempt', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: {} });
|
||||
|
||||
const authenticationError = new Error('Some error');
|
||||
const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
|
||||
mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError);
|
||||
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
|
||||
|
||||
await expect(provider.login(request)).resolves.toEqual(
|
||||
AuthenticationResult.failed(authenticationError)
|
||||
);
|
||||
|
||||
expectAuthenticateCall(mockOptions.client, { headers: { authorization } });
|
||||
|
||||
expect(request.headers).not.toHaveProperty('authorization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('`authenticate` method', () => {
|
||||
it('does not create session for AJAX requests.', async () => {
|
||||
// Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and
|
||||
// avoid triggering of redirect logic.
|
||||
await expect(
|
||||
provider.authenticate(
|
||||
httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }),
|
||||
null
|
||||
)
|
||||
).resolves.toEqual(AuthenticationResult.notHandled());
|
||||
});
|
||||
|
||||
it('does not create session for request that do not require authentication.', async () => {
|
||||
await expect(
|
||||
provider.authenticate(httpServerMock.createKibanaRequest({ routeAuthRequired: false }))
|
||||
).resolves.toEqual(AuthenticationResult.notHandled());
|
||||
});
|
||||
|
||||
it('does not handle authentication via `authorization` header.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: { authorization } });
|
||||
await expect(provider.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.notHandled()
|
||||
);
|
||||
|
||||
expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
|
||||
expect(request.headers.authorization).toBe(authorization);
|
||||
});
|
||||
|
||||
it('does not handle authentication via `authorization` header even if state exists.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: { authorization } });
|
||||
await expect(provider.authenticate(request, {})).resolves.toEqual(
|
||||
AuthenticationResult.notHandled()
|
||||
);
|
||||
|
||||
expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
|
||||
expect(request.headers.authorization).toBe(authorization);
|
||||
});
|
||||
|
||||
it('succeeds for non-AJAX requests if state is available.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: {} });
|
||||
|
||||
const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
|
||||
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user);
|
||||
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
|
||||
|
||||
await expect(provider.authenticate(request, {})).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(user, { authHeaders: { authorization } })
|
||||
);
|
||||
|
||||
expectAuthenticateCall(mockOptions.client, { headers: { authorization } });
|
||||
});
|
||||
|
||||
it('succeeds for AJAX requests if state is available.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } });
|
||||
|
||||
const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
|
||||
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user);
|
||||
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
|
||||
|
||||
await expect(provider.authenticate(request, {})).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(user, { authHeaders: { authorization } })
|
||||
);
|
||||
|
||||
expectAuthenticateCall(mockOptions.client, {
|
||||
headers: { authorization, 'kbn-xsrf': 'xsrf' },
|
||||
});
|
||||
});
|
||||
|
||||
it('non-AJAX requests can start a new session.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: {} });
|
||||
|
||||
const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
|
||||
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user);
|
||||
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
|
||||
|
||||
await expect(provider.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(user, { state: {}, authHeaders: { authorization } })
|
||||
);
|
||||
|
||||
expectAuthenticateCall(mockOptions.client, { headers: { authorization } });
|
||||
});
|
||||
|
||||
it('fails if credentials are not valid.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: {} });
|
||||
|
||||
const authenticationError = new Error('Forbidden');
|
||||
const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
|
||||
mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError);
|
||||
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
|
||||
|
||||
await expect(provider.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.failed(authenticationError)
|
||||
);
|
||||
|
||||
expectAuthenticateCall(mockOptions.client, { headers: { authorization } });
|
||||
|
||||
expect(request.headers).not.toHaveProperty('authorization');
|
||||
});
|
||||
|
||||
if (!useBasicCredentials) {
|
||||
it('properly handles extended format for the ApiKey credentials', async () => {
|
||||
provider = new AnonymousAuthenticationProvider(mockOptions, {
|
||||
credentials: { apiKey: { id: 'some-id', key: 'some-key' } },
|
||||
});
|
||||
authorization = new HTTPAuthorizationHeader(
|
||||
'ApiKey',
|
||||
new BasicHTTPAuthorizationHeaderCredentials('some-id', 'some-key').toString()
|
||||
).toString();
|
||||
|
||||
const request = httpServerMock.createKibanaRequest({ headers: {} });
|
||||
|
||||
const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
|
||||
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user);
|
||||
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
|
||||
|
||||
await expect(provider.authenticate(request, {})).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(user, { authHeaders: { authorization } })
|
||||
);
|
||||
|
||||
expectAuthenticateCall(mockOptions.client, { headers: { authorization } });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('`logout` method', () => {
|
||||
it('does not handle logout if state is not present', async () => {
|
||||
await expect(provider.logout(httpServerMock.createKibanaRequest())).resolves.toEqual(
|
||||
DeauthenticationResult.notHandled()
|
||||
);
|
||||
});
|
||||
|
||||
it('always redirects to the logged out page.', async () => {
|
||||
await expect(provider.logout(httpServerMock.createKibanaRequest(), {})).resolves.toEqual(
|
||||
DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out')
|
||||
);
|
||||
|
||||
await expect(
|
||||
provider.logout(httpServerMock.createKibanaRequest(), null)
|
||||
).resolves.toEqual(
|
||||
DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('`getHTTPAuthenticationScheme` method', () => {
|
||||
expect(provider.getHTTPAuthenticationScheme()).toBe(
|
||||
useBasicCredentials ? 'basic' : 'apikey'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
* 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 { KibanaRequest } from '../../../../../../src/core/server';
|
||||
import { AuthenticationResult } from '../authentication_result';
|
||||
import { canRedirectRequest } from '../can_redirect_request';
|
||||
import { DeauthenticationResult } from '../deauthentication_result';
|
||||
import {
|
||||
BasicHTTPAuthorizationHeaderCredentials,
|
||||
HTTPAuthorizationHeader,
|
||||
} from '../http_authentication';
|
||||
import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base';
|
||||
|
||||
/**
|
||||
* Credentials that are based on the username and password.
|
||||
*/
|
||||
interface UsernameAndPasswordCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials that are based on the Elasticsearch API key.
|
||||
*/
|
||||
interface APIKeyCredentials {
|
||||
apiKey: { id: string; key: string } | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether current request can initiate a new session.
|
||||
* @param request Request instance.
|
||||
*/
|
||||
function canStartNewSession(request: KibanaRequest) {
|
||||
// We should try to establish new session only if request requires authentication and it's not XHR request.
|
||||
// Technically we can authenticate XHR requests too, but we don't want these to create a new session unintentionally.
|
||||
return canRedirectRequest(request) && request.route.options.authRequired === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether specified `credentials` define an API key.
|
||||
* @param credentials
|
||||
*/
|
||||
function isAPIKeyCredentials(
|
||||
credentials: UsernameAndPasswordCredentials | APIKeyCredentials
|
||||
): credentials is APIKeyCredentials {
|
||||
return !!(credentials as APIKeyCredentials).apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider that supports anonymous request authentication.
|
||||
*/
|
||||
export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider {
|
||||
/**
|
||||
* Type of the provider.
|
||||
*/
|
||||
static readonly type = 'anonymous';
|
||||
|
||||
/**
|
||||
* Defines HTTP authorization header that should be used to authenticate request.
|
||||
*/
|
||||
private readonly httpAuthorizationHeader: HTTPAuthorizationHeader;
|
||||
|
||||
constructor(
|
||||
protected readonly options: Readonly<AuthenticationProviderOptions>,
|
||||
anonymousOptions?: Readonly<{
|
||||
credentials?: Readonly<UsernameAndPasswordCredentials | APIKeyCredentials>;
|
||||
}>
|
||||
) {
|
||||
super(options);
|
||||
|
||||
const credentials = anonymousOptions?.credentials;
|
||||
if (!credentials) {
|
||||
throw new Error('Credentials must be specified');
|
||||
}
|
||||
|
||||
if (isAPIKeyCredentials(credentials)) {
|
||||
this.logger.debug('Anonymous requests will be authenticated via API key.');
|
||||
this.httpAuthorizationHeader = new HTTPAuthorizationHeader(
|
||||
'ApiKey',
|
||||
typeof credentials.apiKey === 'string'
|
||||
? credentials.apiKey
|
||||
: new BasicHTTPAuthorizationHeaderCredentials(
|
||||
credentials.apiKey.id,
|
||||
credentials.apiKey.key
|
||||
).toString()
|
||||
);
|
||||
} else {
|
||||
this.logger.debug('Anonymous requests will be authenticated via username and password.');
|
||||
this.httpAuthorizationHeader = new HTTPAuthorizationHeader(
|
||||
'Basic',
|
||||
new BasicHTTPAuthorizationHeaderCredentials(
|
||||
credentials.username,
|
||||
credentials.password
|
||||
).toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs initial login request.
|
||||
* @param request Request instance.
|
||||
* @param state Optional state value previously stored by the provider.
|
||||
*/
|
||||
public async login(request: KibanaRequest, state?: unknown) {
|
||||
this.logger.debug('Trying to perform a login.');
|
||||
return this.authenticateViaAuthorizationHeader(request, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs request authentication.
|
||||
* @param request Request instance.
|
||||
* @param state Optional state value previously stored by the provider.
|
||||
*/
|
||||
public async authenticate(request: KibanaRequest, state?: unknown) {
|
||||
this.logger.debug(
|
||||
`Trying to authenticate user request to ${request.url.pathname}${request.url.search}.`
|
||||
);
|
||||
|
||||
if (HTTPAuthorizationHeader.parseFromRequest(request) != null) {
|
||||
this.logger.debug('Cannot authenticate requests with `Authorization` header.');
|
||||
return AuthenticationResult.notHandled();
|
||||
}
|
||||
|
||||
if (state || canStartNewSession(request)) {
|
||||
return this.authenticateViaAuthorizationHeader(request, state);
|
||||
}
|
||||
|
||||
return AuthenticationResult.notHandled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects user to the logged out page.
|
||||
* @param request Request instance.
|
||||
* @param state Optional state value previously stored by the provider.
|
||||
*/
|
||||
public async logout(request: KibanaRequest, state?: unknown) {
|
||||
this.logger.debug(
|
||||
`Logout is initiated by request to ${request.url.pathname}${request.url.search}.`
|
||||
);
|
||||
|
||||
// Having a `null` state means that provider was specifically called to do a logout, but when
|
||||
// session isn't defined then provider is just being probed whether or not it can perform logout.
|
||||
if (state === undefined) {
|
||||
return DeauthenticationResult.notHandled();
|
||||
}
|
||||
|
||||
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns HTTP authentication scheme (`Basic` or `ApiKey`) that's used within `Authorization`
|
||||
* HTTP header that provider attaches to all successfully authenticated requests to Elasticsearch.
|
||||
*/
|
||||
public getHTTPAuthenticationScheme() {
|
||||
return this.httpAuthorizationHeader.scheme.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to authenticate user request via configured credentials encoded into `Authorization` header.
|
||||
* @param request Request instance.
|
||||
* @param state State value previously stored by the provider.
|
||||
*/
|
||||
private async authenticateViaAuthorizationHeader(request: KibanaRequest, state?: unknown) {
|
||||
const authHeaders = { authorization: this.httpAuthorizationHeader.toString() };
|
||||
try {
|
||||
const user = await this.getUser(request, authHeaders);
|
||||
this.logger.debug(
|
||||
`Request to ${request.url.pathname}${request.url.search} has been authenticated.`
|
||||
);
|
||||
// Create session only if it doesn't exist yet, otherwise keep it unchanged.
|
||||
return AuthenticationResult.succeeded(user, { authHeaders, state: state ? undefined : {} });
|
||||
} catch (err) {
|
||||
this.logger.debug(`Failed to authenticate request : ${err.message}`);
|
||||
return AuthenticationResult.failed(err);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ export {
|
|||
AuthenticationProviderOptions,
|
||||
AuthenticationProviderSpecificOptions,
|
||||
} from './base';
|
||||
export { AnonymousAuthenticationProvider } from './anonymous';
|
||||
export { BasicAuthenticationProvider } from './basic';
|
||||
export { KerberosAuthenticationProvider } from './kerberos';
|
||||
export { SAMLAuthenticationProvider, SAMLLogin } from './saml';
|
||||
|
|
|
@ -28,6 +28,7 @@ describe('config schema', () => {
|
|||
],
|
||||
},
|
||||
"providers": Object {
|
||||
"anonymous": undefined,
|
||||
"basic": Object {
|
||||
"basic": Object {
|
||||
"accessAgreement": undefined,
|
||||
|
@ -76,6 +77,7 @@ describe('config schema', () => {
|
|||
],
|
||||
},
|
||||
"providers": Object {
|
||||
"anonymous": undefined,
|
||||
"basic": Object {
|
||||
"basic": Object {
|
||||
"accessAgreement": undefined,
|
||||
|
@ -124,6 +126,7 @@ describe('config schema', () => {
|
|||
],
|
||||
},
|
||||
"providers": Object {
|
||||
"anonymous": undefined,
|
||||
"basic": Object {
|
||||
"basic": Object {
|
||||
"accessAgreement": undefined,
|
||||
|
@ -863,6 +866,253 @@ describe('config schema', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('`anonymous` provider', () => {
|
||||
it('requires `order`', () => {
|
||||
expect(() =>
|
||||
ConfigSchema.validate({
|
||||
authc: { providers: { anonymous: { anonymous1: { enabled: true } } } },
|
||||
})
|
||||
).toThrow(
|
||||
'[authc.providers.1.anonymous.anonymous1.order]: expected value of type [number] but got [undefined]'
|
||||
);
|
||||
});
|
||||
|
||||
it('requires `credentials`', () => {
|
||||
expect(() =>
|
||||
ConfigSchema.validate({
|
||||
authc: { providers: { anonymous: { anonymous1: { order: 0 } } } },
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`
|
||||
"[authc.providers]: types that failed validation:
|
||||
- [authc.providers.0]: expected value of type [array] but got [Object]
|
||||
- [authc.providers.1.anonymous.anonymous1.credentials]: expected at least one defined value but got [undefined]"
|
||||
`);
|
||||
});
|
||||
|
||||
it('requires both `username` and `password` in username/password `credentials`', () => {
|
||||
expect(() =>
|
||||
ConfigSchema.validate({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: { anonymous1: { order: 0, credentials: { username: 'some-user' } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`
|
||||
"[authc.providers]: types that failed validation:
|
||||
- [authc.providers.0]: expected value of type [array] but got [Object]
|
||||
- [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation:
|
||||
- [credentials.0.password]: expected value of type [string] but got [undefined]
|
||||
- [credentials.1.apiKey]: expected at least one defined value but got [undefined]"
|
||||
`);
|
||||
|
||||
expect(() =>
|
||||
ConfigSchema.validate({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: { anonymous1: { order: 0, credentials: { password: 'some-pass' } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`
|
||||
"[authc.providers]: types that failed validation:
|
||||
- [authc.providers.0]: expected value of type [array] but got [Object]
|
||||
- [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation:
|
||||
- [credentials.0.username]: expected value of type [string] but got [undefined]
|
||||
- [credentials.1.apiKey]: expected at least one defined value but got [undefined]"
|
||||
`);
|
||||
});
|
||||
|
||||
it('can be successfully validated with username/password credentials', () => {
|
||||
expect(
|
||||
ConfigSchema.validate({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 0,
|
||||
credentials: { username: 'some-user', password: 'some-pass' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).authc.providers
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"anonymous": Object {
|
||||
"anonymous1": Object {
|
||||
"credentials": Object {
|
||||
"password": "some-pass",
|
||||
"username": "some-user",
|
||||
},
|
||||
"description": "Continue as Guest",
|
||||
"enabled": true,
|
||||
"hint": "For anonymous users",
|
||||
"icon": "globe",
|
||||
"order": 0,
|
||||
"session": Object {
|
||||
"idleTimeout": null,
|
||||
},
|
||||
"showInSelector": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('requires both `id` and `key` in extended `apiKey` format credentials', () => {
|
||||
expect(() =>
|
||||
ConfigSchema.validate({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: { anonymous1: { order: 0, credentials: { apiKey: { id: 'some-id' } } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`
|
||||
"[authc.providers]: types that failed validation:
|
||||
- [authc.providers.0]: expected value of type [array] but got [Object]
|
||||
- [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation:
|
||||
- [credentials.0.username]: expected value of type [string] but got [undefined]
|
||||
- [credentials.1.apiKey]: types that failed validation:
|
||||
- [credentials.apiKey.0.key]: expected value of type [string] but got [undefined]
|
||||
- [credentials.apiKey.1]: expected value of type [string] but got [Object]"
|
||||
`);
|
||||
|
||||
expect(() =>
|
||||
ConfigSchema.validate({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: {
|
||||
anonymous1: { order: 0, credentials: { apiKey: { key: 'some-key' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`
|
||||
"[authc.providers]: types that failed validation:
|
||||
- [authc.providers.0]: expected value of type [array] but got [Object]
|
||||
- [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation:
|
||||
- [credentials.0.username]: expected value of type [string] but got [undefined]
|
||||
- [credentials.1.apiKey]: types that failed validation:
|
||||
- [credentials.apiKey.0.id]: expected value of type [string] but got [undefined]
|
||||
- [credentials.apiKey.1]: expected value of type [string] but got [Object]"
|
||||
`);
|
||||
});
|
||||
|
||||
it('can be successfully validated with API keys credentials', () => {
|
||||
expect(
|
||||
ConfigSchema.validate({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 0,
|
||||
credentials: { apiKey: 'some-API-key' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).authc.providers
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"anonymous": Object {
|
||||
"anonymous1": Object {
|
||||
"credentials": Object {
|
||||
"apiKey": "some-API-key",
|
||||
},
|
||||
"description": "Continue as Guest",
|
||||
"enabled": true,
|
||||
"hint": "For anonymous users",
|
||||
"icon": "globe",
|
||||
"order": 0,
|
||||
"session": Object {
|
||||
"idleTimeout": null,
|
||||
},
|
||||
"showInSelector": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
expect(
|
||||
ConfigSchema.validate({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 0,
|
||||
credentials: { apiKey: { id: 'some-id', key: 'some-key' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).authc.providers
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"anonymous": Object {
|
||||
"anonymous1": Object {
|
||||
"credentials": Object {
|
||||
"apiKey": Object {
|
||||
"id": "some-id",
|
||||
"key": "some-key",
|
||||
},
|
||||
},
|
||||
"description": "Continue as Guest",
|
||||
"enabled": true,
|
||||
"hint": "For anonymous users",
|
||||
"icon": "globe",
|
||||
"order": 0,
|
||||
"session": Object {
|
||||
"idleTimeout": null,
|
||||
},
|
||||
"showInSelector": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('can be successfully validated with session config overrides', () => {
|
||||
expect(
|
||||
ConfigSchema.validate({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 1,
|
||||
credentials: { username: 'some-user', password: 'some-pass' },
|
||||
session: { idleTimeout: 321, lifespan: 546 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).authc.providers
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"anonymous": Object {
|
||||
"anonymous1": Object {
|
||||
"credentials": Object {
|
||||
"password": "some-pass",
|
||||
"username": "some-user",
|
||||
},
|
||||
"description": "Continue as Guest",
|
||||
"enabled": true,
|
||||
"hint": "For anonymous users",
|
||||
"icon": "globe",
|
||||
"order": 1,
|
||||
"session": Object {
|
||||
"idleTimeout": "PT0.321S",
|
||||
"lifespan": "PT0.546S",
|
||||
},
|
||||
"showInSelector": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it('`name` should be unique across all provider types', () => {
|
||||
expect(() =>
|
||||
ConfigSchema.validate({
|
||||
|
@ -1623,5 +1873,113 @@ describe('createConfig()', () => {
|
|||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('properly handles config for the anonymous provider', async () => {
|
||||
expect(
|
||||
createMockConfig({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 0,
|
||||
credentials: { username: 'some-user', password: 'some-pass' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' })
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"idleTimeout": null,
|
||||
"lifespan": "P30D",
|
||||
}
|
||||
`);
|
||||
|
||||
expect(
|
||||
createMockConfig({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 0,
|
||||
credentials: { username: 'some-user', password: 'some-pass' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { idleTimeout: 0, lifespan: null },
|
||||
}).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' })
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"idleTimeout": null,
|
||||
"lifespan": null,
|
||||
}
|
||||
`);
|
||||
|
||||
expect(
|
||||
createMockConfig({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 0,
|
||||
credentials: { username: 'some-user', password: 'some-pass' },
|
||||
session: { idleTimeout: 0, lifespan: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' })
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"idleTimeout": null,
|
||||
"lifespan": null,
|
||||
}
|
||||
`);
|
||||
|
||||
expect(
|
||||
createMockConfig({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 0,
|
||||
credentials: { username: 'some-user', password: 'some-pass' },
|
||||
session: { idleTimeout: 321, lifespan: 546 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { idleTimeout: null, lifespan: 0 },
|
||||
}).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' })
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"idleTimeout": "PT0.321S",
|
||||
"lifespan": "PT0.546S",
|
||||
}
|
||||
`);
|
||||
|
||||
expect(
|
||||
createMockConfig({
|
||||
authc: {
|
||||
providers: {
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 0,
|
||||
credentials: { username: 'some-user', password: 'some-pass' },
|
||||
session: { idleTimeout: 321, lifespan: 546 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { idleTimeout: 123, lifespan: 456 },
|
||||
}).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' })
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"idleTimeout": "PT0.321S",
|
||||
"lifespan": "PT0.546S",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -51,18 +51,27 @@ function getCommonProviderSchemaProperties(overrides: Partial<ProvidersCommonCon
|
|||
};
|
||||
}
|
||||
|
||||
function getUniqueProviderSchema(
|
||||
function getUniqueProviderSchema<TProperties extends Record<string, Type<any>>>(
|
||||
providerType: string,
|
||||
overrides?: Partial<ProvidersCommonConfigType>
|
||||
overrides?: Partial<ProvidersCommonConfigType>,
|
||||
properties?: TProperties
|
||||
) {
|
||||
return schema.maybe(
|
||||
schema.recordOf(schema.string(), schema.object(getCommonProviderSchemaProperties(overrides)), {
|
||||
validate(config) {
|
||||
if (Object.values(config).filter((provider) => provider.enabled).length > 1) {
|
||||
return `Only one "${providerType}" provider can be configured.`;
|
||||
}
|
||||
},
|
||||
})
|
||||
schema.recordOf(
|
||||
schema.string(),
|
||||
schema.object(
|
||||
properties
|
||||
? { ...getCommonProviderSchemaProperties(overrides), ...properties }
|
||||
: getCommonProviderSchemaProperties(overrides)
|
||||
),
|
||||
{
|
||||
validate(config) {
|
||||
if (Object.values(config).filter((provider) => provider.enabled).length > 1) {
|
||||
return `Only one "${providerType}" provider can be configured.`;
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -120,6 +129,40 @@ const providersConfigSchema = schema.object(
|
|||
schema.object({ ...getCommonProviderSchemaProperties(), realm: schema.string() })
|
||||
)
|
||||
),
|
||||
anonymous: getUniqueProviderSchema(
|
||||
'anonymous',
|
||||
{
|
||||
description: schema.string({
|
||||
defaultValue: i18n.translate('xpack.security.loginAsGuestLabel', {
|
||||
defaultMessage: 'Continue as Guest',
|
||||
}),
|
||||
}),
|
||||
hint: schema.string({
|
||||
defaultValue: i18n.translate('xpack.security.loginAsGuestHintLabel', {
|
||||
defaultMessage: 'For anonymous users',
|
||||
}),
|
||||
}),
|
||||
icon: schema.string({ defaultValue: 'globe' }),
|
||||
session: schema.object({
|
||||
idleTimeout: schema.nullable(schema.duration()),
|
||||
lifespan: schema.maybe(schema.oneOf([schema.duration(), schema.literal(null)])),
|
||||
}),
|
||||
},
|
||||
{
|
||||
credentials: schema.oneOf([
|
||||
schema.object({
|
||||
username: schema.string(),
|
||||
password: schema.string(),
|
||||
}),
|
||||
schema.object({
|
||||
apiKey: schema.oneOf([
|
||||
schema.object({ id: schema.string(), key: schema.string() }),
|
||||
schema.string(),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
validate(config) {
|
||||
|
@ -196,6 +239,7 @@ export const ConfigSchema = schema.object({
|
|||
oidc: undefined,
|
||||
pki: undefined,
|
||||
kerberos: undefined,
|
||||
anonymous: undefined,
|
||||
},
|
||||
}),
|
||||
oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })),
|
||||
|
@ -335,6 +379,7 @@ export function createConfig(
|
|||
}
|
||||
|
||||
function getSessionConfig(session: RawConfigType['session'], providers: ProvidersConfigType) {
|
||||
const defaultAnonymousSessionLifespan = schema.duration().validate('30d');
|
||||
return {
|
||||
cleanupInterval: session.cleanupInterval,
|
||||
getExpirationTimeouts({ type, name }: AuthenticationProvider) {
|
||||
|
@ -343,9 +388,20 @@ function getSessionConfig(session: RawConfigType['session'], providers: Provider
|
|||
// provider doesn't override session config and we should fall back to the global one instead.
|
||||
const providerSessionConfig = providers[type as keyof ProvidersConfigType]?.[name]?.session;
|
||||
|
||||
// We treat anonymous sessions differently since users can create them without realizing it. This may lead to a
|
||||
// non controllable amount of sessions stored in the session index. To reduce the impact we set a 30 days lifespan
|
||||
// for the anonymous sessions in case neither global nor provider specific lifespan is configured explicitly.
|
||||
// We can remove this code once https://github.com/elastic/kibana/issues/68885 is resolved.
|
||||
const providerLifespan =
|
||||
type === 'anonymous' &&
|
||||
providerSessionConfig?.lifespan === undefined &&
|
||||
session.lifespan === undefined
|
||||
? defaultAnonymousSessionLifespan
|
||||
: providerSessionConfig?.lifespan;
|
||||
|
||||
const [idleTimeout, lifespan] = [
|
||||
[session.idleTimeout, providerSessionConfig?.idleTimeout],
|
||||
[session.lifespan, providerSessionConfig?.lifespan],
|
||||
[session.lifespan, providerLifespan],
|
||||
].map(([globalTimeout, providerTimeout]) => {
|
||||
const timeout = providerTimeout === undefined ? globalTimeout ?? null : providerTimeout;
|
||||
return timeout && timeout.asMilliseconds() > 0 ? timeout : null;
|
||||
|
|
|
@ -185,7 +185,7 @@ describe('Login view routes', () => {
|
|||
requiresSecureConnection: false,
|
||||
selector: {
|
||||
enabled: false,
|
||||
providers: [{ name: 'basic', type: 'basic', usesLoginForm: true }],
|
||||
providers: [{ name: 'basic', type: 'basic', usesLoginForm: true, showInSelector: true }],
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
|
@ -209,7 +209,7 @@ describe('Login view routes', () => {
|
|||
requiresSecureConnection: false,
|
||||
selector: {
|
||||
enabled: false,
|
||||
providers: [{ name: 'basic', type: 'basic', usesLoginForm: true }],
|
||||
providers: [{ name: 'basic', type: 'basic', usesLoginForm: true, showInSelector: true }],
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
|
@ -253,6 +253,7 @@ describe('Login view routes', () => {
|
|||
name: 'basic1',
|
||||
type: 'basic',
|
||||
usesLoginForm: true,
|
||||
showInSelector: true,
|
||||
icon: 'logoElasticsearch',
|
||||
description: 'Log in with Elasticsearch',
|
||||
},
|
||||
|
@ -265,6 +266,7 @@ describe('Login view routes', () => {
|
|||
name: 'token1',
|
||||
type: 'token',
|
||||
usesLoginForm: true,
|
||||
showInSelector: true,
|
||||
icon: 'logoElasticsearch',
|
||||
description: 'Log in with Elasticsearch',
|
||||
},
|
||||
|
@ -296,7 +298,7 @@ describe('Login view routes', () => {
|
|||
const contextMock = coreMock.createRequestHandlerContext();
|
||||
|
||||
const cases: Array<[ConfigType['authc'], LoginSelectorProvider[]]> = [
|
||||
// selector is disabled, multiple providers, but only basic provider should be returned.
|
||||
// selector is disabled, multiple providers, all providers should be returned.
|
||||
[
|
||||
getAuthcConfig({
|
||||
selector: { enabled: false },
|
||||
|
@ -310,9 +312,16 @@ describe('Login view routes', () => {
|
|||
name: 'basic1',
|
||||
type: 'basic',
|
||||
usesLoginForm: true,
|
||||
showInSelector: true,
|
||||
icon: 'logoElasticsearch',
|
||||
description: 'Log in with Elasticsearch',
|
||||
},
|
||||
{
|
||||
type: 'saml',
|
||||
name: 'saml1',
|
||||
usesLoginForm: false,
|
||||
showInSelector: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
// selector is enabled, but only basic/token is available and should be returned.
|
||||
|
@ -326,12 +335,13 @@ describe('Login view routes', () => {
|
|||
name: 'basic1',
|
||||
type: 'basic',
|
||||
usesLoginForm: true,
|
||||
showInSelector: true,
|
||||
icon: 'logoElasticsearch',
|
||||
description: 'Log in with Elasticsearch',
|
||||
},
|
||||
],
|
||||
],
|
||||
// selector is enabled, all providers should be returned
|
||||
// selector is enabled
|
||||
[
|
||||
getAuthcConfig({
|
||||
selector: { enabled: true },
|
||||
|
@ -345,7 +355,13 @@ describe('Login view routes', () => {
|
|||
},
|
||||
},
|
||||
saml: {
|
||||
saml1: { order: 1, description: 'some-desc2', realm: 'realm1', icon: 'some-icon2' },
|
||||
saml1: {
|
||||
order: 1,
|
||||
description: 'some-desc2',
|
||||
realm: 'realm1',
|
||||
icon: 'some-icon2',
|
||||
showInSelector: false,
|
||||
},
|
||||
saml2: { order: 2, description: 'some-desc3', hint: 'some-hint3', realm: 'realm2' },
|
||||
},
|
||||
},
|
||||
|
@ -358,6 +374,7 @@ describe('Login view routes', () => {
|
|||
hint: 'some-hint1',
|
||||
icon: 'logoElasticsearch',
|
||||
usesLoginForm: true,
|
||||
showInSelector: true,
|
||||
},
|
||||
{
|
||||
type: 'saml',
|
||||
|
@ -365,6 +382,7 @@ describe('Login view routes', () => {
|
|||
description: 'some-desc2',
|
||||
icon: 'some-icon2',
|
||||
usesLoginForm: false,
|
||||
showInSelector: false,
|
||||
},
|
||||
{
|
||||
type: 'saml',
|
||||
|
@ -372,55 +390,7 @@ describe('Login view routes', () => {
|
|||
description: 'some-desc3',
|
||||
hint: 'some-hint3',
|
||||
usesLoginForm: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
// selector is enabled, only providers that are enabled should be returned.
|
||||
[
|
||||
getAuthcConfig({
|
||||
selector: { enabled: true },
|
||||
providers: {
|
||||
basic: {
|
||||
basic1: {
|
||||
order: 0,
|
||||
description: 'some-desc1',
|
||||
hint: 'some-hint1',
|
||||
icon: 'some-icon1',
|
||||
},
|
||||
},
|
||||
saml: {
|
||||
saml1: {
|
||||
order: 1,
|
||||
description: 'some-desc2',
|
||||
realm: 'realm1',
|
||||
showInSelector: false,
|
||||
},
|
||||
saml2: {
|
||||
order: 2,
|
||||
description: 'some-desc3',
|
||||
hint: 'some-hint3',
|
||||
icon: 'some-icon3',
|
||||
realm: 'realm2',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
[
|
||||
{
|
||||
type: 'basic',
|
||||
name: 'basic1',
|
||||
description: 'some-desc1',
|
||||
hint: 'some-hint1',
|
||||
icon: 'some-icon1',
|
||||
usesLoginForm: true,
|
||||
},
|
||||
{
|
||||
type: 'saml',
|
||||
name: 'saml2',
|
||||
description: 'some-desc3',
|
||||
hint: 'some-hint3',
|
||||
icon: 'some-icon3',
|
||||
usesLoginForm: false,
|
||||
showInSelector: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
|
|
|
@ -55,18 +55,21 @@ export function defineLoginRoutes({
|
|||
const { allowLogin, layout = 'form' } = license.getFeatures();
|
||||
const { sortedProviders, selector } = config.authc;
|
||||
|
||||
const providers = [];
|
||||
for (const { type, name } of sortedProviders) {
|
||||
const providers = sortedProviders.map(({ type, name }) => {
|
||||
// Since `config.authc.sortedProviders` is based on `config.authc.providers` config we can
|
||||
// be sure that config is present for every provider in `config.authc.sortedProviders`.
|
||||
const { showInSelector, description, hint, icon } = config.authc.providers[type]?.[name]!;
|
||||
|
||||
// Include provider into the list if either selector is enabled or provider uses login form.
|
||||
const usesLoginForm = type === 'basic' || type === 'token';
|
||||
if (showInSelector && (usesLoginForm || selector.enabled)) {
|
||||
providers.push({ type, name, usesLoginForm, description, hint, icon });
|
||||
}
|
||||
}
|
||||
return {
|
||||
type,
|
||||
name,
|
||||
usesLoginForm,
|
||||
showInSelector: showInSelector && (usesLoginForm || selector.enabled),
|
||||
description,
|
||||
hint,
|
||||
icon,
|
||||
};
|
||||
});
|
||||
|
||||
const loginState: LoginState = {
|
||||
allowLogin,
|
||||
|
|
|
@ -43,6 +43,7 @@ const onlyNotInCoverageTests = [
|
|||
require.resolve('../test/security_api_integration/oidc.config.ts'),
|
||||
require.resolve('../test/security_api_integration/oidc_implicit_flow.config.ts'),
|
||||
require.resolve('../test/security_api_integration/token.config.ts'),
|
||||
require.resolve('../test/security_api_integration/anonymous.config.ts'),
|
||||
require.resolve('../test/observability_api_integration/basic/config.ts'),
|
||||
require.resolve('../test/observability_api_integration/trial/config.ts'),
|
||||
require.resolve('../test/encrypted_saved_objects_api_integration/config.ts'),
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { FtrProviderContext } from '../ftr_provider_context';
|
||||
import { Role } from '../../../plugins/security/common/model';
|
||||
import { AuthenticatedUser, Role } from '../../../plugins/security/common/model';
|
||||
|
||||
export function SecurityPageProvider({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const browser = getService('browser');
|
||||
|
@ -17,6 +17,7 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider
|
|||
const esArchiver = getService('esArchiver');
|
||||
const userMenu = getService('userMenu');
|
||||
const comboBox = getService('comboBox');
|
||||
const supertest = getService('supertestWithoutAuth');
|
||||
const PageObjects = getPageObjects(['common', 'header', 'error']);
|
||||
|
||||
interface LoginOptions {
|
||||
|
@ -41,10 +42,14 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider
|
|||
});
|
||||
}
|
||||
|
||||
async function isLoginFormVisible() {
|
||||
return await testSubjects.exists('loginForm');
|
||||
}
|
||||
|
||||
async function waitForLoginForm() {
|
||||
log.debug('Waiting for Login Form to appear.');
|
||||
await retry.waitForWithTimeout('login form', config.get('timeouts.waitFor') * 5, async () => {
|
||||
return await testSubjects.exists('loginForm');
|
||||
return await isLoginFormVisible();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -107,7 +112,9 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider
|
|||
|
||||
const loginPage = Object.freeze({
|
||||
async login(username?: string, password?: string, options: LoginOptions = {}) {
|
||||
await PageObjects.common.navigateToApp('login');
|
||||
if (!(await isLoginFormVisible())) {
|
||||
await PageObjects.common.navigateToApp('login');
|
||||
}
|
||||
|
||||
// ensure welcome screen won't be shown. This is relevant for environments which don't allow
|
||||
// to use the yml setting, e.g. cloud
|
||||
|
@ -218,6 +225,21 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider
|
|||
await waitForLoginPage();
|
||||
}
|
||||
|
||||
async getCurrentUser() {
|
||||
const sidCookie = await browser.getCookie('sid');
|
||||
if (!sidCookie?.value) {
|
||||
log.debug('User is not authenticated yet.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { body: user } = await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', `sid=${sidCookie.value}`)
|
||||
.expect(200);
|
||||
return user as AuthenticatedUser;
|
||||
}
|
||||
|
||||
async forceLogout() {
|
||||
log.debug('SecurityPage.forceLogout');
|
||||
if (await find.existsByDisplayedByCssSelector('.login-form', 100)) {
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import { services as kibanaFunctionalServices } from '../../../../test/functional/services';
|
||||
import { services as kibanaApiIntegrationServices } from '../../../../test/api_integration/services';
|
||||
import { services as kibanaXPackApiIntegrationServices } from '../../api_integration/services';
|
||||
import { services as commonServices } from '../../common/services';
|
||||
|
||||
import {
|
||||
|
@ -64,6 +65,7 @@ export const services = {
|
|||
...commonServices,
|
||||
|
||||
supertest: kibanaApiIntegrationServices.supertest,
|
||||
supertestWithoutAuth: kibanaXPackApiIntegrationServices.supertestWithoutAuth,
|
||||
esSupertest: kibanaApiIntegrationServices.esSupertest,
|
||||
monitoringNoData: MonitoringNoDataProvider,
|
||||
monitoringClusterList: MonitoringClusterListProvider,
|
||||
|
|
46
x-pack/test/security_api_integration/anonymous.config.ts
Normal file
46
x-pack/test/security_api_integration/anonymous.config.ts
Normal 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 { FtrConfigProviderContext } from '@kbn/test/types/ftr';
|
||||
|
||||
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
||||
const kibanaAPITestsConfig = await readConfigFile(
|
||||
require.resolve('../../../test/api_integration/config.js')
|
||||
);
|
||||
const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts'));
|
||||
|
||||
return {
|
||||
testFiles: [require.resolve('./tests/anonymous')],
|
||||
servers: xPackAPITestsConfig.get('servers'),
|
||||
security: { disableTestUser: true },
|
||||
services: {
|
||||
...kibanaAPITestsConfig.get('services'),
|
||||
...xPackAPITestsConfig.get('services'),
|
||||
},
|
||||
junit: {
|
||||
reportName: 'X-Pack Security API Integration Tests (Anonymous with Username and Password)',
|
||||
},
|
||||
|
||||
esTestCluster: { ...xPackAPITestsConfig.get('esTestCluster') },
|
||||
|
||||
kbnTestServer: {
|
||||
...xPackAPITestsConfig.get('kbnTestServer'),
|
||||
serverArgs: [
|
||||
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
|
||||
`--xpack.security.authc.selector.enabled=false`,
|
||||
`--xpack.security.authc.providers=${JSON.stringify({
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 0,
|
||||
credentials: { username: 'anonymous_user', password: 'changeme' },
|
||||
},
|
||||
},
|
||||
basic: { basic1: { order: 1 } },
|
||||
})}`,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
|
@ -4,6 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils';
|
||||
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
|
||||
|
@ -35,6 +36,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
|||
kibana: {
|
||||
...xPackAPITestsConfig.get('servers.kibana'),
|
||||
protocol: 'https',
|
||||
certificateAuthorities: [readFileSync(CA_CERT_PATH)],
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -43,9 +45,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
|||
servers,
|
||||
security: { disableTestUser: true },
|
||||
services: {
|
||||
randomness: kibanaAPITestsConfig.get('services.randomness'),
|
||||
legacyEs: kibanaAPITestsConfig.get('services.legacyEs'),
|
||||
supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'),
|
||||
...kibanaAPITestsConfig.get('services'),
|
||||
...xPackAPITestsConfig.get('services'),
|
||||
},
|
||||
junit: {
|
||||
reportName: 'X-Pack Security API Integration Tests (Login Selector)',
|
||||
|
@ -127,6 +128,12 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
|||
useRelayStateDeepLink: true,
|
||||
},
|
||||
},
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 6,
|
||||
credentials: { username: 'anonymous_user', password: 'changeme' },
|
||||
},
|
||||
},
|
||||
})}`,
|
||||
],
|
||||
},
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('security APIs - Anonymous access', function () {
|
||||
this.tags('ciGroup6');
|
||||
loadTestFile(require.resolve('./login'));
|
||||
});
|
||||
}
|
200
x-pack/test/security_api_integration/tests/anonymous/login.ts
Normal file
200
x-pack/test/security_api_integration/tests/anonymous/login.ts
Normal file
|
@ -0,0 +1,200 @@
|
|||
/*
|
||||
* 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 request, { Cookie } from 'request';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertestWithoutAuth');
|
||||
const config = getService('config');
|
||||
const security = getService('security');
|
||||
|
||||
function checkCookieIsSet(cookie: Cookie) {
|
||||
expect(cookie.value).to.not.be.empty();
|
||||
|
||||
expect(cookie.key).to.be('sid');
|
||||
expect(cookie.path).to.be('/');
|
||||
expect(cookie.httpOnly).to.be(true);
|
||||
expect(cookie.maxAge).to.be(null);
|
||||
}
|
||||
|
||||
function checkCookieIsCleared(cookie: Cookie) {
|
||||
expect(cookie.value).to.be.empty();
|
||||
|
||||
expect(cookie.key).to.be('sid');
|
||||
expect(cookie.path).to.be('/');
|
||||
expect(cookie.httpOnly).to.be(true);
|
||||
expect(cookie.maxAge).to.be(0);
|
||||
}
|
||||
|
||||
describe('Anonymous authentication', () => {
|
||||
before(async () => {
|
||||
await security.user.create('anonymous_user', {
|
||||
password: 'changeme',
|
||||
roles: [],
|
||||
full_name: 'Guest',
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await security.user.delete('anonymous_user');
|
||||
});
|
||||
|
||||
it('should reject API requests if client is not authenticated', async () => {
|
||||
await supertest.get('/internal/security/me').set('kbn-xsrf', 'xxx').expect(401);
|
||||
});
|
||||
|
||||
it('does not prevent basic login', async () => {
|
||||
const [username, password] = config.get('servers.elasticsearch.auth').split(':');
|
||||
const response = await supertest
|
||||
.post('/internal/security/login')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({
|
||||
providerType: 'basic',
|
||||
providerName: 'basic1',
|
||||
currentURL: '/',
|
||||
params: { username, password },
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const cookies = response.headers['set-cookie'];
|
||||
expect(cookies).to.have.length(1);
|
||||
|
||||
const cookie = request.cookie(cookies[0])!;
|
||||
checkCookieIsSet(cookie);
|
||||
|
||||
const { body: user } = await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', cookie.cookieString())
|
||||
.expect(200);
|
||||
|
||||
expect(user.username).to.eql(username);
|
||||
expect(user.authentication_provider).to.eql({ type: 'basic', name: 'basic1' });
|
||||
expect(user.authentication_type).to.eql('realm');
|
||||
// Do not assert on the `authentication_realm`, as the value differs for on-prem vs cloud
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should properly set cookie and authenticate user', async () => {
|
||||
const response = await supertest.get('/security/account').expect(200);
|
||||
|
||||
const cookies = response.headers['set-cookie'];
|
||||
expect(cookies).to.have.length(1);
|
||||
|
||||
const sessionCookie = request.cookie(cookies[0])!;
|
||||
checkCookieIsSet(sessionCookie);
|
||||
|
||||
const { body: user } = await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', sessionCookie.cookieString())
|
||||
.expect(200);
|
||||
|
||||
expect(user.username).to.eql('anonymous_user');
|
||||
expect(user.authentication_provider).to.eql({ type: 'anonymous', name: 'anonymous1' });
|
||||
expect(user.authentication_type).to.eql('realm');
|
||||
// Do not assert on the `authentication_realm`, as the value differs for on-prem vs cloud
|
||||
});
|
||||
|
||||
it('should fail if `Authorization` header is present, but not valid', async () => {
|
||||
const response = await supertest
|
||||
.get('/security/account')
|
||||
.set('Authorization', 'Basic wow')
|
||||
.expect(401);
|
||||
expect(response.headers['set-cookie']).to.be(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API access with active session', () => {
|
||||
let sessionCookie: Cookie;
|
||||
|
||||
beforeEach(async () => {
|
||||
const response = await supertest.get('/security/account').expect(200);
|
||||
|
||||
const cookies = response.headers['set-cookie'];
|
||||
expect(cookies).to.have.length(1);
|
||||
|
||||
sessionCookie = request.cookie(cookies[0])!;
|
||||
checkCookieIsSet(sessionCookie);
|
||||
});
|
||||
|
||||
it('should not extend cookie for system AND non-system API calls', async () => {
|
||||
const apiResponseOne = await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', sessionCookie.cookieString())
|
||||
.expect(200);
|
||||
|
||||
expect(apiResponseOne.headers['set-cookie']).to.be(undefined);
|
||||
|
||||
const systemAPIResponse = await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('kbn-system-request', 'true')
|
||||
.set('Cookie', sessionCookie.cookieString())
|
||||
.expect(200);
|
||||
|
||||
expect(systemAPIResponse.headers['set-cookie']).to.be(undefined);
|
||||
});
|
||||
|
||||
it('should fail and preserve session cookie if unsupported authentication schema is used', async () => {
|
||||
const apiResponse = await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Authorization', 'Basic a3JiNTprcmI1')
|
||||
.set('Cookie', sessionCookie.cookieString())
|
||||
.expect(401);
|
||||
|
||||
expect(apiResponse.headers['set-cookie']).to.be(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logging out', () => {
|
||||
it('should redirect to `logged_out` page after successful logout', async () => {
|
||||
// First authenticate user to retrieve session cookie.
|
||||
const response = await supertest.get('/security/account').expect(200);
|
||||
let cookies = response.headers['set-cookie'];
|
||||
expect(cookies).to.have.length(1);
|
||||
|
||||
const sessionCookie = request.cookie(cookies[0])!;
|
||||
checkCookieIsSet(sessionCookie);
|
||||
|
||||
// And then log user out.
|
||||
const logoutResponse = await supertest
|
||||
.get('/api/security/logout')
|
||||
.set('Cookie', sessionCookie.cookieString())
|
||||
.expect(302);
|
||||
|
||||
cookies = logoutResponse.headers['set-cookie'];
|
||||
expect(cookies).to.have.length(1);
|
||||
checkCookieIsCleared(request.cookie(cookies[0])!);
|
||||
|
||||
expect(logoutResponse.headers.location).to.be('/security/logged_out');
|
||||
|
||||
// Old cookie should be invalidated and not allow API access.
|
||||
const apiResponse = await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', sessionCookie.cookieString())
|
||||
.expect(401);
|
||||
|
||||
// If Kibana detects cookie with invalid token it tries to clear it.
|
||||
cookies = apiResponse.headers['set-cookie'];
|
||||
expect(cookies).to.have.length(1);
|
||||
checkCookieIsCleared(request.cookie(cookies[0])!);
|
||||
});
|
||||
|
||||
it('should redirect to home page if session cookie is not provided', async () => {
|
||||
const logoutResponse = await supertest.get('/api/security/logout').expect(302);
|
||||
|
||||
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
|
||||
expect(logoutResponse.headers.location).to.be('/');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -23,6 +23,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
const randomness = getService('randomness');
|
||||
const supertest = getService('supertestWithoutAuth');
|
||||
const config = getService('config');
|
||||
const security = getService('security');
|
||||
|
||||
const kibanaServerConfig = config.get('servers.kibana');
|
||||
const validUsername = kibanaServerConfig.username;
|
||||
|
@ -748,5 +749,68 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anonymous', () => {
|
||||
before(async () => {
|
||||
await security.user.create('anonymous_user', {
|
||||
password: 'changeme',
|
||||
roles: [],
|
||||
full_name: 'Guest',
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await security.user.delete('anonymous_user');
|
||||
});
|
||||
|
||||
it('should be able to log in from Login Selector', async () => {
|
||||
const authenticationResponse = await supertest
|
||||
.post('/internal/security/login')
|
||||
.ca(CA_CERT)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({
|
||||
providerType: 'anonymous',
|
||||
providerName: 'anonymous1',
|
||||
currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const cookies = authenticationResponse.headers['set-cookie'];
|
||||
expect(cookies).to.have.length(1);
|
||||
|
||||
await checkSessionCookie(
|
||||
request.cookie(cookies[0])!,
|
||||
'anonymous_user',
|
||||
{ type: 'anonymous', name: 'anonymous1' },
|
||||
{ name: 'native1', type: 'native' },
|
||||
'realm'
|
||||
);
|
||||
});
|
||||
|
||||
it('should be able to log in from Login Selector even if client provides certificate and PKI is enabled', async () => {
|
||||
const authenticationResponse = await supertest
|
||||
.post('/internal/security/login')
|
||||
.ca(CA_CERT)
|
||||
.pfx(CLIENT_CERT)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({
|
||||
providerType: 'anonymous',
|
||||
providerName: 'anonymous1',
|
||||
currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const cookies = authenticationResponse.headers['set-cookie'];
|
||||
expect(cookies).to.have.length(1);
|
||||
|
||||
await checkSessionCookie(
|
||||
request.cookie(cookies[0])!,
|
||||
'anonymous_user',
|
||||
{ type: 'anonymous', name: 'anonymous1' },
|
||||
{ name: 'native1', type: 'native' },
|
||||
'realm'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -42,7 +42,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
|||
from: 'snapshot',
|
||||
serverArgs: [
|
||||
'xpack.security.authc.token.enabled=true',
|
||||
'xpack.security.authc.realms.saml.saml1.order=0',
|
||||
'xpack.security.authc.realms.native.native1.order=0',
|
||||
'xpack.security.authc.realms.saml.saml1.order=1',
|
||||
`xpack.security.authc.realms.saml.saml1.idp.metadata.path=${idpPath}`,
|
||||
'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co/saml1',
|
||||
`xpack.security.authc.realms.saml.saml1.sp.entity_id=http://localhost:${kibanaPort}`,
|
||||
|
@ -60,15 +61,29 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
|||
'--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d',
|
||||
'--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"',
|
||||
`--xpack.security.loginHelp="Some-login-help."`,
|
||||
'--xpack.security.authc.providers.basic.basic1.order=0',
|
||||
'--xpack.security.authc.providers.saml.saml1.order=1',
|
||||
'--xpack.security.authc.providers.saml.saml1.realm=saml1',
|
||||
'--xpack.security.authc.providers.saml.saml1.description="Log-in-with-SAML"',
|
||||
'--xpack.security.authc.providers.saml.saml1.icon=logoKibana',
|
||||
'--xpack.security.authc.providers.saml.unknown_saml.order=2',
|
||||
'--xpack.security.authc.providers.saml.unknown_saml.realm=unknown_realm',
|
||||
'--xpack.security.authc.providers.saml.unknown_saml.description="Do-not-log-in-with-THIS-SAML"',
|
||||
'--xpack.security.authc.providers.saml.unknown_saml.icon=logoAWS',
|
||||
`--xpack.security.authc.providers=${JSON.stringify({
|
||||
basic: { basic1: { order: 0 } },
|
||||
saml: {
|
||||
saml1: {
|
||||
order: 1,
|
||||
realm: 'saml1',
|
||||
description: 'Log-in-with-SAML',
|
||||
icon: 'logoKibana',
|
||||
},
|
||||
unknown_saml: {
|
||||
order: 2,
|
||||
realm: 'unknown_realm',
|
||||
description: 'Do-not-log-in-with-THIS-SAML',
|
||||
icon: 'logoAWS',
|
||||
},
|
||||
},
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 3,
|
||||
credentials: { username: 'anonymous_user', password: 'changeme' },
|
||||
},
|
||||
},
|
||||
})}`,
|
||||
],
|
||||
},
|
||||
uiSettings: {
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* 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 { parse } from 'url';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const esArchiver = getService('esArchiver');
|
||||
const browser = getService('browser');
|
||||
const security = getService('security');
|
||||
const PageObjects = getPageObjects(['security', 'common']);
|
||||
|
||||
describe('Authentication provider hint', function () {
|
||||
this.tags('includeFirefox');
|
||||
|
||||
before(async () => {
|
||||
await getService('esSupertest')
|
||||
.post('/_security/role_mapping/saml1')
|
||||
.send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } })
|
||||
.expect(200);
|
||||
|
||||
await security.user.create('anonymous_user', {
|
||||
password: 'changeme',
|
||||
roles: ['superuser'],
|
||||
full_name: 'Guest',
|
||||
});
|
||||
|
||||
await esArchiver.load('../../functional/es_archives/empty_kibana');
|
||||
await PageObjects.security.forceLogout();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await security.user.delete('anonymous_user');
|
||||
await esArchiver.unload('../../functional/es_archives/empty_kibana');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await browser.get(`${PageObjects.common.getHostPort()}/login`);
|
||||
await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await PageObjects.security.forceLogout();
|
||||
});
|
||||
|
||||
it('automatically activates Login Form preserving original URL', async () => {
|
||||
await PageObjects.common.navigateToUrlWithBrowserHistory(
|
||||
'management',
|
||||
'/security/users',
|
||||
'?auth_provider_hint=basic1',
|
||||
{ ensureCurrentUrl: false, shouldLoginIfPrompted: false }
|
||||
);
|
||||
await PageObjects.common.waitUntilUrlIncludes('next=');
|
||||
|
||||
// Login form should be automatically activated by the auth provider hint.
|
||||
await PageObjects.security.loginSelector.verifyLoginFormIsVisible();
|
||||
await PageObjects.security.loginPage.login(undefined, undefined, { expectSuccess: true });
|
||||
|
||||
const currentURL = parse(await browser.getCurrentUrl());
|
||||
expect(currentURL.pathname).to.eql('/app/management/security/users');
|
||||
expect((await PageObjects.security.getCurrentUser())?.authentication_provider).to.eql({
|
||||
type: 'basic',
|
||||
name: 'basic1',
|
||||
});
|
||||
});
|
||||
|
||||
it('automatically login with SSO preserving original URL', async () => {
|
||||
await PageObjects.common.navigateToUrlWithBrowserHistory(
|
||||
'management',
|
||||
'/security/users',
|
||||
'?auth_provider_hint=saml1',
|
||||
{ ensureCurrentUrl: false, shouldLoginIfPrompted: false }
|
||||
);
|
||||
|
||||
await PageObjects.common.waitUntilUrlIncludes('/app/management/security/users');
|
||||
|
||||
const currentURL = parse(await browser.getCurrentUrl());
|
||||
expect(currentURL.pathname).to.eql('/app/management/security/users');
|
||||
expect((await PageObjects.security.getCurrentUser())?.authentication_provider).to.eql({
|
||||
type: 'saml',
|
||||
name: 'saml1',
|
||||
});
|
||||
});
|
||||
|
||||
it('can login anonymously preserving original URL', async () => {
|
||||
await PageObjects.common.navigateToUrlWithBrowserHistory(
|
||||
'management',
|
||||
'/security/users',
|
||||
'?auth_provider_hint=anonymous1',
|
||||
{ ensureCurrentUrl: false, shouldLoginIfPrompted: false }
|
||||
);
|
||||
|
||||
await PageObjects.common.waitUntilUrlIncludes('/app/management/security/users');
|
||||
|
||||
const currentURL = parse(await browser.getCurrentUrl());
|
||||
expect(currentURL.pathname).to.eql('/app/management/security/users');
|
||||
expect((await PageObjects.security.getCurrentUser())?.authentication_provider).to.eql({
|
||||
type: 'anonymous',
|
||||
name: 'anonymous1',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
const esArchiver = getService('esArchiver');
|
||||
const testSubjects = getService('testSubjects');
|
||||
const browser = getService('browser');
|
||||
const security = getService('security');
|
||||
const PageObjects = getPageObjects(['security', 'common']);
|
||||
|
||||
describe('Basic functionality', function () {
|
||||
|
@ -71,6 +72,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
expect(currentURL.pathname).to.eql('/app/management/security/users');
|
||||
});
|
||||
|
||||
it('can login anonymously preserving original URL', async () => {
|
||||
await PageObjects.common.navigateToUrl('management', 'security/users', {
|
||||
ensureCurrentUrl: false,
|
||||
shouldLoginIfPrompted: false,
|
||||
shouldUseHashForSubUrl: false,
|
||||
});
|
||||
await PageObjects.common.waitUntilUrlIncludes('next=');
|
||||
|
||||
await security.user.create('anonymous_user', {
|
||||
password: 'changeme',
|
||||
roles: ['superuser'],
|
||||
full_name: 'Guest',
|
||||
});
|
||||
await PageObjects.security.loginSelector.login('anonymous', 'anonymous1');
|
||||
await security.user.delete('anonymous_user');
|
||||
|
||||
// We need to make sure that both path and hash are respected.
|
||||
const currentURL = parse(await browser.getCurrentUrl());
|
||||
expect(currentURL.pathname).to.eql('/app/management/security/users');
|
||||
});
|
||||
|
||||
it('should show toast with error if SSO fails', async () => {
|
||||
await PageObjects.security.loginSelector.selectLoginMethod('saml', 'unknown_saml');
|
||||
|
||||
|
@ -80,6 +102,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible();
|
||||
});
|
||||
|
||||
it('should show toast with error if anonymous login fails', async () => {
|
||||
await PageObjects.security.loginSelector.selectLoginMethod('anonymous', 'anonymous1');
|
||||
|
||||
const toastTitle = await PageObjects.common.closeToast();
|
||||
expect(toastTitle).to.eql('Could not perform login.');
|
||||
|
||||
await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible();
|
||||
});
|
||||
|
||||
it('can go to Login Form and return back to Selector', async () => {
|
||||
await PageObjects.security.loginSelector.selectLoginMethod('basic', 'basic1');
|
||||
await PageObjects.security.loginSelector.verifyLoginFormIsVisible();
|
||||
|
|
|
@ -11,5 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
|
|||
this.tags('ciGroup4');
|
||||
|
||||
loadTestFile(require.resolve('./basic_functionality'));
|
||||
loadTestFile(require.resolve('./auth_provider_hint'));
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue