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 0eccd4692d26eb..ecbdfedac1dd38 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 @@ -110,6 +110,7 @@ exports[`LoginPage enabled form state renders as expected 1`] = ` "add": [MockFunction], "addDanger": [MockFunction], "addError": [MockFunction], + "addInfo": [MockFunction], "addSuccess": [MockFunction], "addWarning": [MockFunction], "get$": [MockFunction], @@ -143,6 +144,7 @@ exports[`LoginPage enabled form state renders as expected when info message is s "add": [MockFunction], "addDanger": [MockFunction], "addError": [MockFunction], + "addInfo": [MockFunction], "addSuccess": [MockFunction], "addWarning": [MockFunction], "get$": [MockFunction], @@ -176,6 +178,7 @@ exports[`LoginPage enabled form state renders as expected when loginAssistanceMe "add": [MockFunction], "addDanger": [MockFunction], "addError": [MockFunction], + "addInfo": [MockFunction], "addSuccess": [MockFunction], "addWarning": [MockFunction], "get$": [MockFunction], @@ -265,6 +268,7 @@ exports[`LoginPage page renders as expected 1`] = ` "add": [MockFunction], "addDanger": [MockFunction], "addError": [MockFunction], + "addInfo": [MockFunction], "addSuccess": [MockFunction], "addWarning": [MockFunction], "get$": [MockFunction], diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap index 2bd99f2c6afe64..9fd32c2d64310b 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap @@ -118,7 +118,7 @@ exports[`LoginForm login selector renders as expected with login form 1`] = ` `; -exports[`LoginForm login selector renders as expected without login form 1`] = ` +exports[`LoginForm login selector renders as expected without login form for providers with and without description 1`] = ` - Login w/PKI + `; 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 cff41f5d26d2dd..c17c10a2c51484 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 @@ -166,7 +166,7 @@ describe('LoginForm', () => { ).toMatchSnapshot(); }); - it('renders as expected without login form', async () => { + it('renders as expected without login form for providers with and without description', async () => { const coreStartMock = coreMock.createStart(); expect( shallowWithIntl( @@ -179,7 +179,7 @@ describe('LoginForm', () => { enabled: true, providers: [ { type: 'saml', name: 'saml1', description: 'Login w/SAML' }, - { type: 'pki', name: 'pki1', description: 'Login w/PKI' }, + { type: 'pki', name: 'pki1' }, ], }} /> @@ -230,5 +230,43 @@ describe('LoginForm', () => { expect(wrapper.find(EuiCallOut).exists()).toBe(false); expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled(); }); + + it('shows error toast if login fails', 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( + + ); + + wrapper.findWhere(node => node.key() === 'saml1').simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', { + 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.', + }); + }); }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 5dc971c8fc6f05..a595b63faaf9b3 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -31,17 +31,19 @@ function getMockOptions({ session, providers, http = {}, + selector, }: { session?: AuthenticatorOptions['config']['session']; providers?: Record | string[]; http?: Partial; + selector?: AuthenticatorOptions['config']['authc']['selector']; } = {}) { return { clusterClient: elasticsearchServiceMock.createClusterClient(), basePath: httpServiceMock.createSetupContract().basePath, loggers: loggingServiceMock.create(), config: createConfig( - ConfigSchema.validate({ session, authc: { providers, http } }), + ConfigSchema.validate({ session, authc: { selector, providers, http } }), loggingServiceMock.create().get(), { isTLSEnabled: false } ), @@ -54,7 +56,7 @@ describe('Authenticator', () => { beforeEach(() => { mockBasicAuthenticationProvider = { login: jest.fn(), - authenticate: jest.fn(), + authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), logout: jest.fn().mockResolvedValue(DeauthenticationResult.notHandled()), getHTTPAuthenticationScheme: jest.fn(), }; @@ -182,6 +184,7 @@ describe('Authenticator', () => { beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(null); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { idleTimeoutExpiration: null, @@ -322,6 +325,7 @@ describe('Authenticator', () => { }, }); mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(null); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); authenticator = new Authenticator(mockOptions); @@ -496,6 +500,7 @@ describe('Authenticator', () => { it('clears session if provider asked to do so.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); + mockSessionStorage.get.mockResolvedValue(mockSessVal); mockBasicAuthenticationProvider.login.mockResolvedValue( AuthenticationResult.succeeded(user, { state: null }) @@ -508,6 +513,26 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + + it('clears legacy session.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + // Use string format for the `provider` session value field to emulate legacy session. + mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(user)); + + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith(request, {}, null); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); }); describe('`authenticate` method', () => { @@ -518,6 +543,7 @@ describe('Authenticator', () => { beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(null); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { idleTimeoutExpiration: null, @@ -931,14 +957,33 @@ describe('Authenticator', () => { expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + it('clears legacy session.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + // Use string format for the `provider` session value field to emulate legacy session. + mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); + + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledWith(request, null); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + it('does not clear session if provider can not handle system API request authentication with active session.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-system-request': 'true' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); mockSessionStorage.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( @@ -954,9 +999,6 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'false' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); mockSessionStorage.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( @@ -972,9 +1014,6 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'true' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: { type: 'token', name: 'token1' }, @@ -993,9 +1032,6 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'false' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: { type: 'token', name: 'token1' }, @@ -1008,6 +1044,70 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + + describe('with Login Selector', () => { + beforeEach(() => { + mockOptions = getMockOptions({ + selector: { enabled: true }, + providers: { basic: { basic1: { order: 0 } } }, + }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + }); + + it('does not redirect to Login Selector if there is an active session', async () => { + const request = httpServerMock.createKibanaRequest(); + mockSessionStorage.get.mockResolvedValue(mockSessVal); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('does not redirect AJAX requests to Login Selector', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('does not redirect to Login Selector if request has `Authorization` header', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic ***' }, + }); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('does not redirect to Login Selector if it is not enabled', async () => { + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + authenticator = new Authenticator(mockOptions); + + const request = httpServerMock.createKibanaRequest(); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('redirects to the Login Selector when needed.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fpath' + ) + ); + expect(mockBasicAuthenticationProvider.authenticate).not.toHaveBeenCalled(); + }); + }); }); describe('`logout` method', () => { diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 22abfb0f05967b..2f96d6fa7a00f6 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -143,6 +143,15 @@ function isLoginAttemptWithProviderType( ); } +/** + * Determines if session value was created by the previous Kibana versions which had a different + * session value format. + * @param sessionValue The session value to check. + */ +function isLegacyProviderSession(sessionValue: any) { + return typeof sessionValue?.provider === 'string'; +} + /** * Instantiates authentication provider based on the provider key from config. * @param providerType Provider type key. @@ -503,18 +512,19 @@ export class Authenticator { * @param sessionStorage Session storage instance. */ private async getSessionValue(sessionStorage: SessionStorage) { - let sessionValue = await sessionStorage.get(); + const sessionValue = await sessionStorage.get(); - // If for some reason we have a session stored for the provider that is not available - // (e.g. when user was logged in with one provider, but then configuration has changed - // and that provider is no longer available), then we should clear session entirely. - const sessionProvider = sessionValue && this.providers.get(sessionValue.provider?.name); + // If we detect that session is in incompatible format or for some reason we have a session + // stored for the provider that is not available anymore (e.g. when user was logged in with one + // provider, but then configuration has changed and that provider is no longer available), then + // we should clear session entirely. if ( sessionValue && - (!sessionProvider || sessionProvider.type !== sessionValue?.provider.type) + (isLegacyProviderSession(sessionValue) || + this.providers.get(sessionValue.provider.name)?.type !== sessionValue.provider.type) ) { sessionStorage.clear(); - sessionValue = null; + return null; } return sessionValue; diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts index f0f33d6624cc18..abab67c9cd1d28 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.ts @@ -111,7 +111,7 @@ export function defineCommonRoutes({ router, authc, basePath, logger }: RouteDef const { providerType, providerName, currentURL } = request.body; logger.info(`Logging in with provider "${providerName}" (${providerType})`); - const redirectURL = parseNext(currentURL ?? '', basePath.serverBasePath); + const redirectURL = parseNext(currentURL, basePath.serverBasePath); try { const authenticationResult = await authc.login(request, { provider: { name: providerName }, diff --git a/x-pack/test/login_selector_api_integration/apis/login_selector.ts b/x-pack/test/login_selector_api_integration/apis/login_selector.ts index 489f0265da57a4..3be96d27186d9a 100644 --- a/x-pack/test/login_selector_api_integration/apis/login_selector.ts +++ b/x-pack/test/login_selector_api_integration/apis/login_selector.ts @@ -275,10 +275,6 @@ export default function({ getService }: FtrProviderContext) { } }); - // Ideally we should be able to abandon intermediate session and let user log in, but for the - // time being we cannot distinguish errors coming from Elasticsearch for the case when SAML - // response just doesn't correspond to request ID we have in intermediate cookie and the case - // when something else has happened. it('should be able to log in via SP initiated login even if intermediate session with other SAML provider exists', async () => { // First start authentication flow with `saml1`. const saml1HandshakeResponse = await supertest @@ -383,6 +379,50 @@ export default function({ getService }: FtrProviderContext) { 'kerberos1' ); }); + + it('should be able to log in from Login Selector even if client provides certificate and PKI is enabled', async () => { + const spnegoResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(401); + + expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); + + const authenticationResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Authorization', `Negotiate ${getSPNEGOToken()}`) + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + // Verify that mutual authentication works. + expect(authenticationResponse.headers['www-authenticate']).to.be( + `Negotiate ${getMutualAuthenticationResponseToken()}` + ); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie( + request.cookie(cookies[0])!, + 'tester@TEST.ELASTIC.CO', + 'kerberos1' + ); + }); }); describe('OpenID Connect', () => {