From 03d11a3ca63fe25e126e683bb81c39f4b0c0f220 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 6 Oct 2020 16:18:24 +0200 Subject: [PATCH 1/4] Implement AnonymousAuthenticationProvider. --- test/functional/services/common/browser.ts | 12 + x-pack/plugins/security/common/constants.ts | 2 + .../common/model/authenticated_user.test.ts | 13 + .../common/model/authenticated_user.ts | 5 +- .../__snapshots__/login_page.test.tsx.snap | 41 --- .../components/login_form/login_form.scss | 8 + .../components/login_form/login_form.tsx | 79 ++++- .../authentication/login/login_page.test.tsx | 13 +- .../authentication/login/login_page.tsx | 5 +- .../nav_control_component.test.tsx | 47 ++- .../nav_control/nav_control_component.tsx | 18 +- .../server/authentication/authenticator.ts | 18 +- .../providers/anonymous.test.ts | 245 ++++++++++++++ .../authentication/providers/anonymous.ts | 217 ++++++++++++ .../server/authentication/providers/index.ts | 1 + x-pack/plugins/security/server/config.test.ts | 316 ++++++++++++++++++ x-pack/plugins/security/server/config.ts | 74 +++- x-pack/scripts/functional_tests.js | 1 + .../functional/page_objects/security_page.ts | 28 +- x-pack/test/functional/services/index.ts | 2 + .../anonymous.config.ts | 46 +++ .../login_selector.config.ts | 13 +- .../tests/anonymous/index.ts | 14 + .../tests/anonymous/login.ts | 200 +++++++++++ .../login_selector/basic_functionality.ts | 64 ++++ .../login_selector.config.ts | 35 +- .../login_selector/auth_provider_hint.ts | 107 ++++++ .../login_selector/basic_functionality.ts | 31 ++ .../tests/login_selector/index.ts | 1 + 29 files changed, 1557 insertions(+), 99 deletions(-) create mode 100644 x-pack/plugins/security/server/authentication/providers/anonymous.test.ts create mode 100644 x-pack/plugins/security/server/authentication/providers/anonymous.ts create mode 100644 x-pack/test/security_api_integration/anonymous.config.ts create mode 100644 x-pack/test/security_api_integration/tests/anonymous/index.ts create mode 100644 x-pack/test/security_api_integration/tests/anonymous/login.ts create mode 100644 x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts diff --git a/test/functional/services/common/browser.ts b/test/functional/services/common/browser.ts index b3b7fd32eae19f..8f03b1d7602582 100644 --- a/test/functional/services/common/browser.ts +++ b/test/functional/services/common/browser.ts @@ -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} + */ + 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} diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts index a0d63c0a9dd6f3..07e6ab6c72cb94 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -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'; diff --git a/x-pack/plugins/security/common/model/authenticated_user.test.ts b/x-pack/plugins/security/common/model/authenticated_user.test.ts index d253fed97f353e..6eb428adf2cd5f 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.test.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.test.ts @@ -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`, () => { diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index d5c8d4e474c601..c22c5fc4ef0dad 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -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' + ); } diff --git a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap index 8af75633776e89..64d456c3c6b0ad 100644 --- a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap +++ b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap @@ -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`] = ` - -`; - exports[`LoginPage enabled form state renders as expected when loginAssistanceMessage is set 1`] = ` { private readonly validator: LoginValidator; + /** + * Optional provider that was suggested by the `authProviderHint={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 +111,17 @@ export class LoginForm extends Component { }; } + 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 ( {this.renderLoginAssistanceMessage()} @@ -267,7 +294,7 @@ export class LoginForm extends Component { 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,32 @@ export class LoginForm extends Component { return null; }; + private renderAutoLoginOverlay = () => { + return ( + + + {this.props.selector.providers.map(() => ( + + ))} + + + + + + + + + + + + + + ); + }; + private setUsernameInputRef(ref: HTMLInputElement) { if (ref) { ref.focus(); @@ -438,9 +491,17 @@ export class LoginForm extends Component { } }; - 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 +527,9 @@ export class LoginForm extends Component { } }; - 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; diff --git a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx index 467b2a7ff99062..7110c8e130ac17 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx @@ -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( { 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 () => { diff --git a/x-pack/plugins/security/public/authentication/login/login_page.tsx b/x-pack/plugins/security/public/authentication/login/login_page.tsx index be152b21e27015..06469626842848 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.tsx @@ -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 { ); } + const query = parse(window.location.href, true).query; return ( ); }; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index 4a2b86447b7f78..27e6b99d43b077 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -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, + user: new Promise(() => 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, + 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, + 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, + 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, + 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(); + 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(1); + expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); + + expect(findTestSubject(wrapper, 'logoutLink').text()).toBe('Log in'); + }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index c22308fa8a43e0..29145e02602558 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -131,12 +131,18 @@ export class SecurityNavControl extends Component { }; const logoutMenuItem = { - name: ( - - ), + name: + authenticatedUser?.authentication_provider.type === 'anonymous' ? ( + + ) : ( + + ), icon: , href: logoutUrl, 'data-test-subj': 'logoutLink', diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index eef45598d1761c..718415e4857251 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -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 && diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts new file mode 100644 index 00000000000000..617fa9aedecc11 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts @@ -0,0 +1,245 @@ +/* + * 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, + 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; + let authorization: string; + beforeEach(() => { + mockOptions = mockAuthenticationProviderOptions({ name: 'anonymous1' }); + + provider = useBasicCredentials + ? new AnonymousAuthenticationProvider(mockOptions, { + credentials: { username: 'user', password: 'pass' }, + }) + : new AnonymousAuthenticationProvider(mockOptions, { + credentials: { username: 'user', 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('rewrites `username` it specified in ApiKey credentials', async () => { + const userReturnedFromES = mockAuthenticatedUser({ + username: 'user-created-apikey', + authentication_provider: { type: 'anonymous', name: 'anonymous1' }, + }); + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(userReturnedFromES); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request, {})).resolves.toEqual( + AuthenticationResult.succeeded( + { ...userReturnedFromES, username: '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' + ); + }); + }); + } +}); diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.ts new file mode 100644 index 00000000000000..930f395aaba352 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.ts @@ -0,0 +1,217 @@ +/* + * 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 with the optional username override. + */ +interface APIKeyCredentials { + username?: string; + apiKey: 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 { + const apiKey = ((credentials as unknown) as Record).apiKey; + return typeof apiKey === 'string' && !!apiKey; +} + +/** + * Provider that supports anonymous request authentication. + */ +export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider { + /** + * Type of the provider. + */ + static readonly type = 'anonymous'; + + /** + * Specifies credentials that should be used to authenticate anonymous user. + */ + private readonly credentials: Readonly; + + constructor( + protected readonly options: Readonly, + anonymousOptions?: Readonly<{ + credentials?: Readonly; + }> + ) { + super(options); + + if (!anonymousOptions || !anonymousOptions.credentials) { + throw new Error('Credentials must be specified'); + } + this.credentials = anonymousOptions.credentials; + } + + /** + * 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.'); + + // If authentication succeeded we should return some state to create a session, that will reuse for all subsequent + // requests and anonymous user interactions with the Kibana. + return isAPIKeyCredentials(this.credentials) + ? await this.authenticateViaAPIKey(request, this.credentials, state) + : await this.authenticateViaUsernameAndPassword(request, this.credentials, 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 isAPIKeyCredentials(this.credentials) + ? await this.authenticateViaAPIKey(request, this.credentials, state) + : await this.authenticateViaUsernameAndPassword(request, this.credentials, 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 isAPIKeyCredentials(this.credentials) ? 'apikey' : 'basic'; + } + + /** + * Tries to authenticate user request via configured username and password. + * @param request Request instance. + * @param credentials Credentials based on the username and password. + * @param state State value previously stored by the provider. + */ + private async authenticateViaUsernameAndPassword( + request: KibanaRequest, + { username, password }: UsernameAndPasswordCredentials, + state?: unknown + ) { + this.logger.debug('Trying to authenticate request via anonymous username and password.'); + + try { + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials(username, password).toString() + ).toString(), + }; + + const user = await this.getUser(request, authHeaders); + this.logger.debug( + `Request to ${request.url.pathname}${request.url.search} has been authenticated.` + ); + return AuthenticationResult.succeeded(user, { + authHeaders, + // Create session only if it doesn't exist yet, otherwise keep it unchanged. + state: state ? undefined : {}, + }); + } catch (err) { + this.logger.debug( + `Failed to authenticate request via anonymous username and password: ${err.message}` + ); + return AuthenticationResult.failed(err); + } + } + + /** + * Tries to authenticate user in via configured api key. + * @param request Request instance. + * @param credentials Credentials based on the API key. + * @param state State value previously stored by the provider. + */ + private async authenticateViaAPIKey( + request: KibanaRequest, + { apiKey, username }: APIKeyCredentials, + state?: unknown + ) { + this.logger.debug('Trying to authenticate request via API key.'); + + const authHeaders = { + authorization: new HTTPAuthorizationHeader('ApiKey', apiKey).toString(), + }; + try { + const user = await this.getUser(request, authHeaders); + this.logger.debug( + `Request to ${request.url.pathname}${request.url.search} has been authenticated.` + ); + return AuthenticationResult.succeeded(username ? { ...user, username } : user, { + authHeaders, + // Create session only if it doesn't exist yet, otherwise keep it unchanged. + state: state ? undefined : {}, + }); + } catch (err) { + this.logger.debug(`Failed to authenticate request via API key: ${err.message}`); + return AuthenticationResult.failed(err); + } + } +} diff --git a/x-pack/plugins/security/server/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts index 048afb6190d18c..cfa9e715050669 100644 --- a/x-pack/plugins/security/server/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/authentication/providers/index.ts @@ -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'; diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index e75c0d1c4085fe..6781a203e0647b 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -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,211 @@ 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 value of type [string] 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 value of type [string] 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('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: 'some-API-key', username: 'some-user' }, + }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "anonymous": Object { + "anonymous1": Object { + "credentials": Object { + "apiKey": "some-API-key", + "username": "some-user", + }, + "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 +1831,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", + } + `); + }); }); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 4da0a8598309a3..b7691431e9a070 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -51,18 +51,27 @@ function getCommonProviderSchemaProperties(overrides: Partial>>( providerType: string, - overrides?: Partial + overrides?: Partial, + 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,38 @@ 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({ + username: schema.maybe(schema.string()), + apiKey: schema.string(), + }), + ]), + } + ), }, { validate(config) { @@ -196,6 +237,7 @@ export const ConfigSchema = schema.object({ oidc: undefined, pki: undefined, kerberos: undefined, + anonymous: undefined, }, }), oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })), @@ -335,6 +377,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 +386,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; diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 7d4cc41cfbe5a3..505ad3c7d866b3 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -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'), diff --git a/x-pack/test/functional/page_objects/security_page.ts b/x-pack/test/functional/page_objects/security_page.ts index 2d9ee00234bb67..ef80ab475cbd67 100644 --- a/x-pack/test/functional/page_objects/security_page.ts +++ b/x-pack/test/functional/page_objects/security_page.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)) { diff --git a/x-pack/test/functional/services/index.ts b/x-pack/test/functional/services/index.ts index 6d8eade25d7e6d..1aa6216236827d 100644 --- a/x-pack/test/functional/services/index.ts +++ b/x-pack/test/functional/services/index.ts @@ -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, diff --git a/x-pack/test/security_api_integration/anonymous.config.ts b/x-pack/test/security_api_integration/anonymous.config.ts new file mode 100644 index 00000000000000..1742bd09c92f5a --- /dev/null +++ b/x-pack/test/security_api_integration/anonymous.config.ts @@ -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 } }, + })}`, + ], + }, + }; +} diff --git a/x-pack/test/security_api_integration/login_selector.config.ts b/x-pack/test/security_api_integration/login_selector.config.ts index 9688d42cb43617..97c7b4334c3b7b 100644 --- a/x-pack/test/security_api_integration/login_selector.config.ts +++ b/x-pack/test/security_api_integration/login_selector.config.ts @@ -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' }, + }, + }, })}`, ], }, diff --git a/x-pack/test/security_api_integration/tests/anonymous/index.ts b/x-pack/test/security_api_integration/tests/anonymous/index.ts new file mode 100644 index 00000000000000..3819d26ae5efa5 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/anonymous/index.ts @@ -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')); + }); +} diff --git a/x-pack/test/security_api_integration/tests/anonymous/login.ts b/x-pack/test/security_api_integration/tests/anonymous/login.ts new file mode 100644 index 00000000000000..9d4d72e9d7e2a8 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/anonymous/login.ts @@ -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 spnegoResponse = await supertest + .get('/security/account') + .set('Authorization', 'Basic wow') + .expect(401); + expect(spnegoResponse.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('/'); + }); + }); + }); +} diff --git a/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts index cf141972b044a1..edcc1b5744fe37 100644 --- a/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts +++ b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts @@ -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' + ); + }); + }); }); } diff --git a/x-pack/test/security_functional/login_selector.config.ts b/x-pack/test/security_functional/login_selector.config.ts index 9fc4c54ba13444..2ee47491c5ff38 100644 --- a/x-pack/test/security_functional/login_selector.config.ts +++ b/x-pack/test/security_functional/login_selector.config.ts @@ -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: { diff --git a/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts b/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts new file mode 100644 index 00000000000000..8c208625590927 --- /dev/null +++ b/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts @@ -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', + }); + }); + }); +} diff --git a/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts index 153387c52e5c3c..a08fae4cdb0a13 100644 --- a/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts +++ b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts @@ -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(); diff --git a/x-pack/test/security_functional/tests/login_selector/index.ts b/x-pack/test/security_functional/tests/login_selector/index.ts index 0d1060fbf1f513..ee25e365d495d3 100644 --- a/x-pack/test/security_functional/tests/login_selector/index.ts +++ b/x-pack/test/security_functional/tests/login_selector/index.ts @@ -11,5 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { this.tags('ciGroup4'); loadTestFile(require.resolve('./basic_functionality')); + loadTestFile(require.resolve('./auth_provider_hint')); }); } From 43aea1f894df637f5c7e54f2f95d48eb87154322 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 20 Nov 2020 09:34:05 +0100 Subject: [PATCH 2/4] Review#1: add support for the extended format of the ApiKey credentials, use simpler auto login overlay, hide profile link, add login form tests. --- .../components/login_form/login_form.scss | 8 - .../components/login_form/login_form.test.tsx | 137 ++++++++++++++++++ .../components/login_form/login_form.tsx | 45 +++--- .../nav_control_component.test.tsx | 2 +- .../nav_control/nav_control_component.tsx | 58 ++++---- .../providers/anonymous.test.ts | 21 +-- .../authentication/providers/anonymous.ts | 113 +++++---------- x-pack/plugins/security/server/config.test.ts | 52 ++++++- x-pack/plugins/security/server/config.ts | 6 +- .../tests/anonymous/login.ts | 4 +- 10 files changed, 290 insertions(+), 156 deletions(-) diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.scss b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.scss index 5a97396893adbc..344cde9c7825ce 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.scss +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.scss @@ -41,14 +41,6 @@ + .secLoginCard { border-top: $euiBorderThin; } - - &.secLoginCard-autoLogin { - border-color: transparent; - - + .secLoginCard { - padding-top: unset; - } - } } .secLoginCard__hint { diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx index e6d170122751ec..5a1b5a05263e33 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -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', { @@ -591,4 +608,124 @@ 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( + + ); + + 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 does not need login form', 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( + + ); + + 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( + + ); + + 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?'); + }); + }); }); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx index 346bc6e7d2cf88..9b16701990ae61 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -25,7 +25,6 @@ import { EuiLoadingSpinner, EuiLink, EuiHorizontalRule, - EuiLoadingContent, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -79,7 +78,7 @@ export class LoginForm extends Component { private readonly validator: LoginValidator; /** - * Optional provider that was suggested by the `authProviderHint={providerName}` query string parameter. If provider + * 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. */ @@ -138,7 +137,7 @@ export class LoginForm extends Component { } return ( -
+
{this.props.loginAssistanceMessage} @@ -389,27 +388,25 @@ export class LoginForm extends Component { private renderAutoLoginOverlay = () => { return ( - - - {this.props.selector.providers.map(() => ( - - ))} - - - - - - - - - - - - - + + + + + + + + + + ); }; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index 27e6b99d43b077..66b8002788dcbb 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -175,7 +175,7 @@ describe('SecurityNavControl', () => { wrapper.find(EuiHeaderSectionItemButton).simulate('click'); expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); + expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); expect(findTestSubject(wrapper, 'logoutLink').text()).toBe('Log in'); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index 29145e02602558..e846539025452f 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -118,39 +118,23 @@ export class SecurityNavControl extends Component { ); - const profileMenuItem = { - name: ( - - ), - icon: , - href: editProfileUrl, - 'data-test-subj': 'profileLink', - }; + const isAnonymousUser = authenticatedUser?.authentication_provider.type === 'anonymous'; + const items: EuiContextMenuPanelItemDescriptor[] = []; - const logoutMenuItem = { - name: - authenticatedUser?.authentication_provider.type === 'anonymous' ? ( + if (!isAnonymousUser) { + const profileMenuItem = { + name: ( - ) : ( - ), - icon: , - href: logoutUrl, - 'data-test-subj': 'logoutLink', - }; - - const items: EuiContextMenuPanelItemDescriptor[] = []; - - items.push(profileMenuItem); + icon: , + href: editProfileUrl, + 'data-test-subj': 'profileLink', + }; + items.push(profileMenuItem); + } if (userMenuLinks.length) { const userMenuLinkMenuItems = userMenuLinks @@ -168,6 +152,22 @@ export class SecurityNavControl extends Component { }); } + const logoutMenuItem = { + name: isAnonymousUser ? ( + + ) : ( + + ), + icon: , + href: logoutUrl, + 'data-test-subj': 'logoutLink', + }; items.push(logoutMenuItem); const panels = [ diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts index 617fa9aedecc11..c296cb9c8e94d5 100644 --- a/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts @@ -47,7 +47,7 @@ describe('AnonymousAuthenticationProvider', () => { credentials: { username: 'user', password: 'pass' }, }) : new AnonymousAuthenticationProvider(mockOptions, { - credentials: { username: 'user', apiKey: 'some-apiKey' }, + credentials: { apiKey: 'some-apiKey' }, }); authorization = useBasicCredentials ? new HTTPAuthorizationHeader( @@ -192,22 +192,23 @@ describe('AnonymousAuthenticationProvider', () => { }); if (!useBasicCredentials) { - it('rewrites `username` it specified in ApiKey credentials', async () => { - const userReturnedFromES = mockAuthenticatedUser({ - username: 'user-created-apikey', - authentication_provider: { type: 'anonymous', name: 'anonymous1' }, + 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(userReturnedFromES); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, {})).resolves.toEqual( - AuthenticationResult.succeeded( - { ...userReturnedFromES, username: 'user' }, - { authHeaders: { authorization } } - ) + AuthenticationResult.succeeded(user, { authHeaders: { authorization } }) ); expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.ts index 930f395aaba352..6f02cce371a413 100644 --- a/x-pack/plugins/security/server/authentication/providers/anonymous.ts +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.ts @@ -23,11 +23,10 @@ interface UsernameAndPasswordCredentials { } /** - * Credentials that are based on the Elasticsearch API key with the optional username override. + * Credentials that are based on the Elasticsearch API key. */ interface APIKeyCredentials { - username?: string; - apiKey: string; + apiKey: { id: string; key: string } | string; } /** @@ -47,8 +46,7 @@ function canStartNewSession(request: KibanaRequest) { function isAPIKeyCredentials( credentials: UsernameAndPasswordCredentials | APIKeyCredentials ): credentials is APIKeyCredentials { - const apiKey = ((credentials as unknown) as Record).apiKey; - return typeof apiKey === 'string' && !!apiKey; + return !!(credentials as APIKeyCredentials).apiKey; } /** @@ -61,9 +59,9 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider static readonly type = 'anonymous'; /** - * Specifies credentials that should be used to authenticate anonymous user. + * Defines HTTP authorization header that should be used to authenticate request. */ - private readonly credentials: Readonly; + private readonly httpAuthorizationHeader: HTTPAuthorizationHeader; constructor( protected readonly options: Readonly, @@ -73,10 +71,32 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider ) { super(options); - if (!anonymousOptions || !anonymousOptions.credentials) { + const credentials = anonymousOptions?.credentials; + if (!credentials) { throw new Error('Credentials must be specified'); } - this.credentials = anonymousOptions.credentials; + + 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() + ); + } } /** @@ -86,12 +106,7 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider */ public async login(request: KibanaRequest, state?: unknown) { this.logger.debug('Trying to perform a login.'); - - // If authentication succeeded we should return some state to create a session, that will reuse for all subsequent - // requests and anonymous user interactions with the Kibana. - return isAPIKeyCredentials(this.credentials) - ? await this.authenticateViaAPIKey(request, this.credentials, state) - : await this.authenticateViaUsernameAndPassword(request, this.credentials, state); + return this.authenticateViaAuthorizationHeader(request, state); } /** @@ -110,9 +125,7 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider } if (state || canStartNewSession(request)) { - return isAPIKeyCredentials(this.credentials) - ? await this.authenticateViaAPIKey(request, this.credentials, state) - : await this.authenticateViaUsernameAndPassword(request, this.credentials, state); + return this.authenticateViaAuthorizationHeader(request, state); } return AuthenticationResult.notHandled(); @@ -142,75 +155,25 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider * HTTP header that provider attaches to all successfully authenticated requests to Elasticsearch. */ public getHTTPAuthenticationScheme() { - return isAPIKeyCredentials(this.credentials) ? 'apikey' : 'basic'; - } - - /** - * Tries to authenticate user request via configured username and password. - * @param request Request instance. - * @param credentials Credentials based on the username and password. - * @param state State value previously stored by the provider. - */ - private async authenticateViaUsernameAndPassword( - request: KibanaRequest, - { username, password }: UsernameAndPasswordCredentials, - state?: unknown - ) { - this.logger.debug('Trying to authenticate request via anonymous username and password.'); - - try { - const authHeaders = { - authorization: new HTTPAuthorizationHeader( - 'Basic', - new BasicHTTPAuthorizationHeaderCredentials(username, password).toString() - ).toString(), - }; - - const user = await this.getUser(request, authHeaders); - this.logger.debug( - `Request to ${request.url.pathname}${request.url.search} has been authenticated.` - ); - return AuthenticationResult.succeeded(user, { - authHeaders, - // Create session only if it doesn't exist yet, otherwise keep it unchanged. - state: state ? undefined : {}, - }); - } catch (err) { - this.logger.debug( - `Failed to authenticate request via anonymous username and password: ${err.message}` - ); - return AuthenticationResult.failed(err); - } + return this.httpAuthorizationHeader.scheme.toLowerCase(); } /** - * Tries to authenticate user in via configured api key. + * Tries to authenticate user request via configured credentials encoded into `Authorization` header. * @param request Request instance. - * @param credentials Credentials based on the API key. * @param state State value previously stored by the provider. */ - private async authenticateViaAPIKey( - request: KibanaRequest, - { apiKey, username }: APIKeyCredentials, - state?: unknown - ) { - this.logger.debug('Trying to authenticate request via API key.'); - - const authHeaders = { - authorization: new HTTPAuthorizationHeader('ApiKey', apiKey).toString(), - }; + 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.` ); - return AuthenticationResult.succeeded(username ? { ...user, username } : user, { - authHeaders, - // Create session only if it doesn't exist yet, otherwise keep it unchanged. - state: state ? undefined : {}, - }); + // 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 via API key: ${err.message}`); + this.logger.debug(`Failed to authenticate request : ${err.message}`); return AuthenticationResult.failed(err); } } diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index baf21020b7e4ff..62a3e4b706b8e8 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -903,7 +903,7 @@ describe('config schema', () => { - [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 value of type [string] but got [undefined]" + - [credentials.1.apiKey]: expected at least one defined value but got [undefined]" `); expect(() => @@ -919,7 +919,7 @@ describe('config schema', () => { - [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 value of type [string] but got [undefined]" + - [credentials.1.apiKey]: expected at least one defined value but got [undefined]" `); }); @@ -960,6 +960,46 @@ describe('config schema', () => { `); }); + it('requires both `id` and `key` in extend `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({ @@ -1002,7 +1042,7 @@ describe('config schema', () => { anonymous: { anonymous1: { order: 0, - credentials: { apiKey: 'some-API-key', username: 'some-user' }, + credentials: { apiKey: { id: 'some-id', key: 'some-key' } }, }, }, }, @@ -1013,8 +1053,10 @@ describe('config schema', () => { "anonymous": Object { "anonymous1": Object { "credentials": Object { - "apiKey": "some-API-key", - "username": "some-user", + "apiKey": Object { + "id": "some-id", + "key": "some-key", + }, }, "description": "Continue as Guest", "enabled": true, diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index c187c92af82fee..b46c8dc2178a40 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -155,8 +155,10 @@ const providersConfigSchema = schema.object( password: schema.string(), }), schema.object({ - username: schema.maybe(schema.string()), - apiKey: schema.string(), + apiKey: schema.oneOf([ + schema.object({ id: schema.string(), key: schema.string() }), + schema.string(), + ]), }), ]), } diff --git a/x-pack/test/security_api_integration/tests/anonymous/login.ts b/x-pack/test/security_api_integration/tests/anonymous/login.ts index 9d4d72e9d7e2a8..e7c876f54ee5a0 100644 --- a/x-pack/test/security_api_integration/tests/anonymous/login.ts +++ b/x-pack/test/security_api_integration/tests/anonymous/login.ts @@ -102,11 +102,11 @@ export default function ({ getService }: FtrProviderContext) { }); it('should fail if `Authorization` header is present, but not valid', async () => { - const spnegoResponse = await supertest + const response = await supertest .get('/security/account') .set('Authorization', 'Basic wow') .expect(401); - expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + expect(response.headers['set-cookie']).to.be(undefined); }); }); From a9c4e0656a63e4ee63b2f6e8f4e65dc092c7956e Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 20 Nov 2020 16:38:25 +0100 Subject: [PATCH 3/4] Review#2: support auth_provider_hint for providers that are hidden in Login Selector. --- x-pack/plugins/security/common/login_state.ts | 1 + .../components/login_form/login_form.test.tsx | 131 ++++++++++++++---- .../components/login_form/login_form.tsx | 7 +- .../server/routes/views/login.test.ts | 78 ++++------- .../security/server/routes/views/login.ts | 19 +-- 5 files changed, 143 insertions(+), 93 deletions(-) diff --git a/x-pack/plugins/security/common/login_state.ts b/x-pack/plugins/security/common/login_state.ts index fd2b1cb8d1cf7e..77edd1a4ea8ddc 100644 --- a/x-pack/plugins/security/common/login_state.ts +++ b/x-pack/plugins/security/common/login_state.ts @@ -10,6 +10,7 @@ export interface LoginSelectorProvider { type: string; name: string; usesLoginForm: boolean; + showInSelector: boolean; description?: string; hint?: string; icon?: string; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx index 5a1b5a05263e33..2b67f204848843 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -74,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 }, + ], }} /> ) @@ -91,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 }], }} /> ); @@ -111,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 }], }} /> ); @@ -132,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 }], }} /> ); @@ -164,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 }], }} /> ); @@ -197,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 }], }} /> ); @@ -239,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 }], }} /> ); @@ -278,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, }, ], }} @@ -326,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 }, ], }} /> @@ -369,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, + }, ], }} /> @@ -414,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 }, ], }} /> @@ -462,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 }, ], }} /> @@ -505,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 }, ], }} /> @@ -534,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 }, ], }} /> @@ -571,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 }, ], }} /> @@ -622,8 +651,8 @@ describe('LoginForm', () => { selector={{ enabled: true, providers: [ - { type: 'basic', name: 'basic1', usesLoginForm: true }, - { type: 'saml', name: 'saml1', usesLoginForm: false }, + { type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true }, ], }} /> @@ -634,7 +663,51 @@ describe('LoginForm', () => { expect(findTestSubject(wrapper, 'loginAssistanceMessage').text()).toEqual('Need assistance?'); }); - it('automatically logs in if provider suggested by the auth provider hint does not need login form', async () => { + 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( + + ); + + 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=()' )}`; @@ -654,8 +727,8 @@ describe('LoginForm', () => { selector={{ enabled: true, providers: [ - { type: 'basic', name: 'basic1', usesLoginForm: true }, - { type: 'saml', name: 'saml1', usesLoginForm: false }, + { type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: false }, ], }} /> @@ -698,8 +771,8 @@ describe('LoginForm', () => { selector={{ enabled: true, providers: [ - { type: 'basic', name: 'basic1', usesLoginForm: true }, - { type: 'saml', name: 'saml1', usesLoginForm: false }, + { type: 'basic', name: 'basic1', usesLoginForm: true, showInSelector: true }, + { type: 'saml', name: 'saml1', usesLoginForm: false, showInSelector: true }, ], }} /> diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx index 9b16701990ae61..e37d0024852d74 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -283,9 +283,10 @@ export class LoginForm extends Component { }; private renderSelector = () => { + const providers = this.props.selector.providers.filter((provider) => provider.showInSelector); return ( - {this.props.selector.providers.map((provider) => ( + {providers.map((provider) => (