[6.8] Allow SAML IdP initiated login when SAML authentication provider is NOT configured as the first provider. (#60240)

This commit is contained in:
Aleh Zasypkin 2020-03-17 07:29:59 +01:00 committed by GitHub
parent 34147e8f79
commit dc91d17ffc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 509 additions and 12 deletions

View file

@ -134,7 +134,7 @@ class Authenticator {
const existingSession = await this._session.get(request);
let authenticationResult;
for (const [providerType, provider] of this._providerIterator(existingSession)) {
for (const [providerType, provider] of this._providerIterator(existingSession, request)) {
// Check if current session has been set by this provider.
const ownsSession = existingSession && existingSession.provider === providerType;
@ -208,7 +208,7 @@ class Authenticator {
// SP associated with the current user session to do the logout. So if Kibana (without active session)
// receives such a request it shouldn't redirect user to the home page, but rather redirect back to IdP
// with correct logout response and only Elasticsearch knows how to do that.
if (request.query.SAMLRequest && this._providers.has('saml')) {
if (this._isSAMLRequest(request) && this._providers.has('saml')) {
return this._providers.get('saml').deauthenticate(request);
}
@ -234,19 +234,22 @@ class Authenticator {
/**
* Returns provider iterator where providers are sorted in the order of priority (based on the session ownership).
* @param {Object} sessionValue Current session value.
* @param {Hapi.Request} request HapiJS request instance.
* @returns {Iterator.<Object>}
*/
*_providerIterator(sessionValue) {
// If there is no session to predict which provider to use first, let's use the order
// providers are configured in. Otherwise return provider that owns session first, and only then the rest
// of providers.
if (!sessionValue) {
*_providerIterator(sessionValue, request) {
// If there is no way to predict which provider to use first, let's use the order providers are configured in.
// Otherwise return provider that either owns session or can handle 3rd-party login request first, and only then
// the rest of providers.
const shouldHandleSAMLResponse = this._isSAMLResponse(request) && this._providers.has('saml');
if (!sessionValue && !shouldHandleSAMLResponse) {
yield* this._providers;
} else {
yield [sessionValue.provider, this._providers.get(sessionValue.provider)];
const provider = shouldHandleSAMLResponse ? 'saml' : sessionValue.provider;
yield [provider, this._providers.get(provider)];
for (const [providerType, provider] of this._providers) {
if (providerType !== sessionValue.provider) {
if (providerType !== provider) {
yield [providerType, provider];
}
}
@ -273,6 +276,27 @@ class Authenticator {
return sessionValue;
}
/**
* Checks whether specified request represents SAML Request.
* @param {Hapi.Request} request HapiJS request instance.
* @returns {boolean}
* @private
*/
_isSAMLRequest(request) {
return !!(request.query && request.query.SAMLRequest);
}
/**
* Checks whether specified request represents SAML Response.
* @param {Hapi.Request} request HapiJS request instance.
* @returns {boolean}
* @private
*/
_isSAMLResponse(request) {
return !!(request.payload && request.payload.SAMLResponse)
&& request.path === '/api/security/v1/saml';
}
}
export async function initAuthenticator(server, authorizationMode) {

View file

@ -21,6 +21,7 @@ require('@kbn/test').runTestsCli([
require.resolve('../test/api_integration/config.js'),
require.resolve('../test/plugin_api_integration/config.js'),
require.resolve('../test/saml_api_integration/config.js'),
require.resolve('../test/saml_api_integration/with_basic.config.js'),
require.resolve('../test/token_api_integration/config.js'),
require.resolve('../test/spaces_api_integration/spaces_only/config'),
require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial'),

View file

@ -7,6 +7,6 @@
export default function ({ loadTestFile }) {
describe('apis SAML', function () {
this.tags('ciGroup6');
loadTestFile(require.resolve('./security'));
loadTestFile(require.resolve('./saml_login'));
});
}

View file

@ -5,7 +5,8 @@
*/
export default function ({ loadTestFile }) {
describe('security', () => {
describe('apis SAML (with Basic)', function () {
this.tags('ciGroup6');
loadTestFile(require.resolve('./saml_login'));
});
}

View file

@ -0,0 +1,443 @@
/*
* 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 querystring from 'querystring';
import url from 'url';
import { delay } from 'bluebird';
import { getLogoutRequest, getSAMLResponse } from '../../fixtures/saml_tools';
import expect from 'expect.js';
import request from 'request';
export default function ({ getService }) {
const chance = getService('chance');
const supertest = getService('supertestWithoutAuth');
const config = getService('config');
const kibanaServerConfig = config.get('servers.kibana');
const basicUsername = kibanaServerConfig.username;
const basicPassword = kibanaServerConfig.password;
function createSAMLResponse(options = {}) {
return getSAMLResponse({
destination: `http://localhost:${kibanaServerConfig.port}/api/security/v1/saml`,
sessionIndex: chance.natural(),
...options,
});
}
function createLogoutRequest(options = {}) {
return getLogoutRequest({
destination: `http://localhost:${kibanaServerConfig.port}/logout`,
...options,
});
}
describe('SAML authentication (IdP initiated login)', () => {
describe('initiating handshake', () => {
it('should not initiate handshake and redirect to Kibana own login page.', async () => {
const response = await supertest.get('/abc/xyz/handshake?one=two three')
.expect(302);
expect(response.headers['set-cookie']).to.be(undefined);
expect(response.headers.location).to.eql('/login?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three');
});
});
describe('finishing handshake', () => {
let basicCookie;
beforeEach(async () => {
const loginResponse = await supertest.post('/api/security/v1/login')
.set('kbn-xsrf', 'xxx')
.send({ username: basicUsername, password: basicPassword })
.expect(204);
basicCookie = request.cookie(loginResponse.headers['set-cookie'][0]);
});
it('should succeed in case of IdP initiated login', async () => {
const samlAuthenticationResponse = await supertest.post('/api/security/v1/saml')
.set('kbn-xsrf', 'xxx')
.send({ SAMLResponse: await createSAMLResponse() }, {})
.expect(302);
expect(samlAuthenticationResponse.headers.location).to.be('/');
const cookies = samlAuthenticationResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
const sessionCookie = request.cookie(cookies[0]);
expect(sessionCookie.key).to.be('sid');
expect(sessionCookie.value).to.not.be.empty();
expect(sessionCookie.path).to.be('/');
expect(sessionCookie.httpOnly).to.be(true);
const apiResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
expect(apiResponse.body).to.only.have.keys([
'username',
'full_name',
'email',
'roles',
'scope',
'metadata',
'enabled',
'authentication_realm',
'lookup_realm',
]);
expect(apiResponse.body.username).to.be('a@b.c');
});
it('should succeed if both SAML response and basic cookie are provided', async () => {
const samlAuthenticationResponse = await supertest.post('/api/security/v1/saml')
.set('kbn-xsrf', 'xxx')
.set('Cookie', basicCookie.cookieString())
.send({ SAMLResponse: await createSAMLResponse() }, {})
.expect(302);
expect(samlAuthenticationResponse.headers.location).to.be('/');
const cookies = samlAuthenticationResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
const sessionCookie = request.cookie(cookies[0]);
expect(sessionCookie.key).to.be('sid');
expect(sessionCookie.value).to.not.be.empty();
expect(sessionCookie.path).to.be('/');
expect(sessionCookie.httpOnly).to.be(true);
const apiResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
expect(apiResponse.body).to.only.have.keys([
'username',
'full_name',
'email',
'roles',
'scope',
'metadata',
'enabled',
'authentication_realm',
'lookup_realm',
]);
expect(apiResponse.body.username).to.be('a@b.c');
});
it('should fail if SAML response is not valid', async () => {
await supertest.post('/api/security/v1/saml')
.set('kbn-xsrf', 'xxx')
.set('Cookie', basicCookie.cookieString())
.send({ SAMLResponse: await createSAMLResponse({ inResponseTo: 'some-invalid-request-id' }) }, {})
.expect(401);
});
});
describe('logging out', () => {
let sessionCookie;
let idpSessionIndex;
beforeEach(async () => {
idpSessionIndex = chance.natural();
const samlAuthenticationResponse = await supertest.post('/api/security/v1/saml')
.set('kbn-xsrf', 'xxx')
.send({ SAMLResponse: await createSAMLResponse({ sessionIndex: idpSessionIndex }) }, {})
.expect(302);
sessionCookie = request.cookie(samlAuthenticationResponse.headers['set-cookie'][0]);
});
it('should redirect to IdP with SAML request to complete logout', async () => {
const logoutResponse = await supertest.get('/api/security/v1/logout')
.set('Cookie', sessionCookie.cookieString())
.expect(302);
const cookies = logoutResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
const logoutCookie = request.cookie(cookies[0]);
expect(logoutCookie.key).to.be('sid');
expect(logoutCookie.value).to.be.empty();
expect(logoutCookie.path).to.be('/');
expect(logoutCookie.httpOnly).to.be(true);
expect(logoutCookie.maxAge).to.be(0);
const redirectURL = url.parse(logoutResponse.headers.location, true /* parseQueryString */);
expect(redirectURL.href.startsWith(`https://elastic.co/slo/saml`)).to.be(true);
expect(redirectURL.query.SAMLRequest).to.not.be.empty();
// Tokens that were stored in the previous cookie should be invalidated as well and old
// session cookie should not allow API access.
const apiResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
expect(apiResponse.body).to.eql({
error: 'Bad Request',
message: 'Both access and refresh tokens are expired.',
statusCode: 400
});
});
it('should redirect to home page if session cookie is not provided', async () => {
const logoutResponse = await supertest.get('/api/security/v1/logout')
.expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
expect(logoutResponse.headers.location).to.be('/');
});
it('should reject AJAX requests', async () => {
const ajaxResponse = await supertest.get('/api/security/v1/logout')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
expect(ajaxResponse.headers['set-cookie']).to.be(undefined);
expect(ajaxResponse.body).to.eql({
error: 'Bad Request',
message: 'Client should be able to process redirect response.',
statusCode: 400
});
});
it('should invalidate access token on IdP initiated logout', async () => {
const logoutRequest = await createLogoutRequest({ sessionIndex: idpSessionIndex });
const logoutResponse = await supertest.get(`/api/security/v1/logout?${querystring.stringify(logoutRequest)}`)
.set('Cookie', sessionCookie.cookieString())
.expect(302);
const cookies = logoutResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
const logoutCookie = request.cookie(cookies[0]);
expect(logoutCookie.key).to.be('sid');
expect(logoutCookie.value).to.be.empty();
expect(logoutCookie.path).to.be('/');
expect(logoutCookie.httpOnly).to.be(true);
expect(logoutCookie.maxAge).to.be(0);
const redirectURL = url.parse(logoutResponse.headers.location, true /* parseQueryString */);
expect(redirectURL.href.startsWith(`https://elastic.co/slo/saml`)).to.be(true);
expect(redirectURL.query.SAMLResponse).to.not.be.empty();
// Tokens that were stored in the previous cookie should be invalidated as well and old session
// cookie should not allow API access.
const apiResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
expect(apiResponse.body).to.eql({
error: 'Bad Request',
message: 'Both access and refresh tokens are expired.',
statusCode: 400
});
});
it('should invalidate access token on IdP initiated logout even if there is no Kibana session', async () => {
const logoutRequest = await createLogoutRequest({ sessionIndex: idpSessionIndex });
const logoutResponse = await supertest.get(`/api/security/v1/logout?${querystring.stringify(logoutRequest)}`)
.expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
const redirectURL = url.parse(logoutResponse.headers.location, true /* parseQueryString */);
expect(redirectURL.href.startsWith(`https://elastic.co/slo/saml`)).to.be(true);
expect(redirectURL.query.SAMLResponse).to.not.be.empty();
// Elasticsearch should find and invalidate access and refresh tokens that correspond to provided
// IdP session id (encoded in SAML LogoutRequest) even if Kibana doesn't provide them and session
// cookie with these tokens should not allow API access.
const apiResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
expect(apiResponse.body).to.eql({
error: 'Bad Request',
message: 'Both access and refresh tokens are expired.',
statusCode: 400
});
});
});
describe('API access with expired access token.', () => {
let sessionCookie;
beforeEach(async () => {
const samlAuthenticationResponse = await supertest.post('/api/security/v1/saml')
.set('kbn-xsrf', 'xxx')
.send({ SAMLResponse: await createSAMLResponse() }, {})
.expect(302);
sessionCookie = request.cookie(samlAuthenticationResponse.headers['set-cookie'][0]);
});
it('expired access token should be automatically refreshed', async function () {
this.timeout(40000);
// Access token expiration is set to 15s for API integration tests.
// Let's wait for 20s to make sure token expires.
await delay(20000);
// This api call should succeed and automatically refresh token. Returned cookie will contain
// the new access and refresh token pair.
const apiResponse = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
const cookies = apiResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
const newSessionCookie = request.cookie(cookies[0]);
expect(newSessionCookie.key).to.be('sid');
expect(newSessionCookie.value).to.not.be.empty();
expect(newSessionCookie.path).to.be('/');
expect(newSessionCookie.httpOnly).to.be(true);
expect(newSessionCookie.value).to.not.be(sessionCookie.value);
// Request with old cookie should fail with `400` since it contains expired access token and
// already used refresh tokens.
const apiResponseWithExpiredToken = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
expect(apiResponseWithExpiredToken.headers['set-cookie']).to.be(undefined);
expect(apiResponseWithExpiredToken.body).to.eql({
error: 'Bad Request',
message: 'Both access and refresh tokens are expired.',
statusCode: 400
});
// The new cookie with fresh pair of access and refresh tokens should work.
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', newSessionCookie.cookieString())
.expect(200);
});
it('expired access token should be automatically refreshed with two concurrent requests', async function () {
this.timeout(40000);
// Access token expiration is set to 15s for API integration tests.
// Let's wait for 20s to make sure token expires.
await delay(20000);
// Issue two concurrent requests with the same cookie that contains expired access token.
// First request that uses refresh token should succeed, the second should fail since refresh
// token is one-time use only token.
const apiResponseOnePromise = supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(200);
const apiResponseTwoPromise = supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
const apiResponseOne = await apiResponseOnePromise;
const cookies = apiResponseOne.headers['set-cookie'];
expect(cookies).to.have.length(1);
const newSessionCookie = request.cookie(cookies[0]);
expect(newSessionCookie.key).to.be('sid');
expect(newSessionCookie.value).to.not.be.empty();
expect(newSessionCookie.path).to.be('/');
expect(newSessionCookie.httpOnly).to.be(true);
expect(newSessionCookie.value).to.not.be(sessionCookie.value);
const apiResponseTwo = await apiResponseTwoPromise;
expect(apiResponseTwo.headers['set-cookie']).to.be(undefined);
expect(apiResponseTwo.body).to.eql({
error: 'Bad Request',
message: 'Both access and refresh tokens are expired.',
statusCode: 400
});
// Request with old cookie should fail with `400` since it contains expired access token and
// already used refresh tokens.
const apiResponseWithExpiredToken = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(400);
expect(apiResponseWithExpiredToken.body).to.eql({
error: 'Bad Request',
message: 'Both access and refresh tokens are expired.',
statusCode: 400
});
// The new cookie with fresh pair of access and refresh tokens should work.
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'xxx')
.set('Cookie', newSessionCookie.cookieString())
.expect(200);
});
});
describe('API access with missing access token document.', () => {
let sessionCookie;
beforeEach(async () => {
const samlAuthenticationResponse = await supertest.post('/api/security/v1/saml')
.set('kbn-xsrf', 'xxx')
.send({ SAMLResponse: await createSAMLResponse() }, {})
.expect(302);
sessionCookie = request.cookie(samlAuthenticationResponse.headers['set-cookie'][0]);
});
it('should properly set cookie and start new SAML handshake', async function () {
// Let's delete tokens from `.security` index directly to simulate the case when
// Elasticsearch automatically removes access/refresh token document from the index
// after some period of time.
const esResponse = await getService('es').deleteByQuery({
index: '.security',
q: 'doc_type:token',
refresh: true,
});
expect(esResponse).to.have.property('deleted').greaterThan(0);
const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three')
.set('Cookie', sessionCookie.cookieString())
.expect(302);
const cookies = handshakeResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
const handshakeCookie = request.cookie(cookies[0]);
expect(handshakeCookie.key).to.be('sid');
expect(handshakeCookie.value).to.not.be.empty();
expect(handshakeCookie.path).to.be('/');
expect(handshakeCookie.httpOnly).to.be(true);
const redirectURL = url.parse(handshakeResponse.headers.location, true /* parseQueryString */);
expect(redirectURL.href.startsWith(`https://elastic.co/sso/saml`)).to.be(true);
expect(redirectURL.query.SAMLRequest).to.not.be.empty();
});
});
});
}

View file

@ -41,7 +41,7 @@ export default async function ({ readConfigFile }) {
];
return {
testFiles: [require.resolve('./apis')],
testFiles: [require.resolve('./apis/saml_only')],
servers: xPackAPITestsConfig.get('servers'),
services: {
chance: kibanaAPITestsConfig.get('services.chance'),

View file

@ -0,0 +1,28 @@
/*
* 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.
*/
export default async function ({ readConfigFile }) {
const samlAPITestsConfig = await readConfigFile(require.resolve('./config.js'));
return {
testFiles: [require.resolve('./apis/with_basic')],
servers: samlAPITestsConfig.get('servers'),
services: samlAPITestsConfig.get('services'),
junit: {
reportName: 'X-Pack SAML API Integration Tests (with Basic)',
},
esTestCluster: samlAPITestsConfig.get('esTestCluster'),
kbnTestServer: {
...samlAPITestsConfig.get('kbnTestServer'),
serverArgs: [
...samlAPITestsConfig.get('kbnTestServer.serverArgs'),
'--xpack.security.authProviders=[\"basic\",\"saml\"]',
],
},
};
}