Implement AnonymousAuthenticationProvider. (#79985)

This commit is contained in:
Aleh Zasypkin 2020-11-23 11:41:08 +01:00 committed by GitHub
parent 197b9dd582
commit e3ca8a928d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1847 additions and 205 deletions

View file

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

View file

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

View file

@ -10,6 +10,7 @@ export interface LoginSelectorProvider {
type: string;
name: string;
usesLoginForm: boolean;
showInSelector: boolean;
description?: string;
hint?: string;
icon?: string;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
},
],
],

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { 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 } },
})}`,
],
},
};
}

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,5 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
this.tags('ciGroup4');
loadTestFile(require.resolve('./basic_functionality'));
loadTestFile(require.resolve('./auth_provider_hint'));
});
}