[security] Token auth provider (#26997) (#27503)

* [security] Token auth provider

From a user perspective, the token provider behaves similarly to the
basic provider in that it can power the native login experience and can
also be used for API calls via the authorization header (albeit with the
Bearer realm).

From a technical perspective, the token provider deals with
authentication via the token service in Elasticsearch, so while it
handles user credentials in the case of login, a temporary, refreshable
access token is stored in the session cookie instead. This means that
when you log out, not only is the cookie invalidated, but the token
itself cannot be reused.

* token provider integration tests

* include token api integration tests by default

* remove unused ProviderOptions from typedef

* assert that valid login sets an authorization header

* unit tests for refresh token and failure cases

* integration tests for headers and sessions

* clean up login/logout tests for consistent setup functions

* test for header rejection scenarios
This commit is contained in:
Court Ewing 2018-12-21 11:19:44 -05:00 committed by GitHub
parent d413f9db26
commit 1fb934b70c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 1281 additions and 1 deletions

View file

@ -8,6 +8,7 @@ import { getClient } from '../../../../../server/lib/get_client_shield';
import { AuthScopeService } from '../auth_scope_service';
import { BasicAuthenticationProvider } from './providers/basic';
import { SAMLAuthenticationProvider } from './providers/saml';
import { TokenAuthenticationProvider } from './providers/token';
import { AuthenticationResult } from './authentication_result';
import { DeauthenticationResult } from './deauthentication_result';
import { Session } from './session';
@ -17,7 +18,8 @@ import { LoginAttempt } from './login_attempt';
// provider class that can handle specific authentication mechanism.
const providerMap = new Map([
['basic', BasicAuthenticationProvider],
['saml', SAMLAuthenticationProvider]
['saml', SAMLAuthenticationProvider],
['token', TokenAuthenticationProvider],
]);
function assertRequest(request) {

View file

@ -0,0 +1,510 @@
/*
* 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 'expect.js';
import sinon from 'sinon';
import { requestFixture } from '../../../__tests__/__fixtures__/request';
import { LoginAttempt } from '../../login_attempt';
import { TokenAuthenticationProvider } from '../token';
describe('TokenAuthenticationProvider', () => {
describe('`authenticate` method', () => {
let provider;
let callWithRequest;
let callWithInternalUser;
beforeEach(() => {
callWithRequest = sinon.stub();
callWithInternalUser = sinon.stub();
provider = new TokenAuthenticationProvider({
client: { callWithRequest, callWithInternalUser },
log() {},
basePath: '/base-path'
});
});
it('does not redirect AJAX requests that can not be authenticated to the login page.', async () => {
// Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and
// avoid triggering of redirect logic.
const authenticationResult = await provider.authenticate(
requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }),
null
);
expect(authenticationResult.notHandled()).to.be(true);
});
it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ path: '/some-path # that needs to be encoded', basePath: '/s/foo' }),
null
);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be(
'/base-path/login?next=%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded'
);
});
it('does not handle authentication if state exists, but accessToken property is missing.',
async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }),
{}
);
expect(authenticationResult.notHandled()).to.be(true);
});
it('succeeds with valid login attempt and stores in session', async () => {
const user = { username: 'user' };
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
request.loginAttempt.returns(loginAttempt);
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'password', username: 'user', password: 'password' } })
.returns(Promise.resolve({ access_token: 'foo', refresh_token: 'bar' }));
callWithRequest
.withArgs(request, 'shield.authenticate')
.returns(Promise.resolve(user));
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql(user);
expect(authenticationResult.state).to.be.eql({ accessToken: 'foo', refreshToken: 'bar' });
expect(request.headers.authorization).to.be.eql(`Bearer foo`);
sinon.assert.calledOnce(callWithRequest);
});
it('succeeds if only `authorization` header is available.', async () => {
const authorization = 'Bearer foo';
const request = requestFixture({ headers: { authorization } });
const user = { username: 'user' };
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
.returns(Promise.resolve(user));
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql(user);
sinon.assert.calledOnce(callWithRequest);
});
it('does not return session state for header-based auth', async () => {
const authorization = 'Bearer foo';
const request = requestFixture({ headers: { authorization } });
const user = { username: 'user' };
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
.returns(Promise.resolve(user));
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.state).not.to.eql({ authorization: request.headers.authorization });
});
it('succeeds if only state is available.', async () => {
const request = requestFixture();
const accessToken = 'foo';
const user = { username: 'user' };
const authorization = `Bearer ${accessToken}`;
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
.returns(Promise.resolve(user));
const authenticationResult = await provider.authenticate(request, { accessToken });
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql(user);
expect(authenticationResult.state).to.be.eql(undefined);
sinon.assert.calledOnce(callWithRequest);
});
it('succeeds with valid session even if requiring a token refresh', async () => {
const user = { username: 'user' };
const request = requestFixture();
callWithRequest
.withArgs(sinon.match({ headers: { authorization: 'Bearer foo' } }), 'shield.authenticate')
.returns(Promise.reject({ body: { error: { reason: 'token expired' } } }));
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } })
.returns(Promise.resolve({ access_token: 'newfoo', refresh_token: 'newbar' }));
callWithRequest
.withArgs(sinon.match({ headers: { authorization: 'Bearer newfoo' } }), 'shield.authenticate')
.returns(user);
const accessToken = 'foo';
const refreshToken = 'bar';
const authenticationResult = await provider.authenticate(request, { accessToken, refreshToken });
sinon.assert.calledTwice(callWithRequest);
sinon.assert.calledOnce(callWithInternalUser);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql(user);
expect(authenticationResult.state).to.be.eql({ accessToken: 'newfoo', refreshToken: 'newbar' });
});
it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => {
const request = requestFixture({ headers: { authorization: 'Basic ***' } });
const accessToken = 'foo';
const user = { username: 'user' };
const authorization = `Bearer ${accessToken}`;
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
.returns(Promise.resolve(user));
const authenticationResult = await provider.authenticate(request, { accessToken });
sinon.assert.notCalled(callWithRequest);
expect(request.headers.authorization).to.be('Basic ***');
expect(authenticationResult.notHandled()).to.be(true);
});
it('fails if state contains invalid credentials.', async () => {
const request = requestFixture();
const accessToken = 'foo';
const authorization = `Bearer ${accessToken}`;
const authenticationError = new Error('Forbidden');
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
.returns(Promise.reject(authenticationError));
const authenticationResult = await provider.authenticate(request, { accessToken });
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(authenticationError);
sinon.assert.calledOnce(callWithRequest);
});
it('authenticates only via `authorization` header even if state is available.', async () => {
const accessToken = 'foo';
const authorization = `Bearer ${accessToken}`;
const request = requestFixture({ headers: { authorization } });
const user = { username: 'user' };
// GetUser will be called with request's `authorization` header.
callWithRequest.withArgs(request, 'shield.authenticate').returns(Promise.resolve(user));
const authenticationResult = await provider.authenticate(request, { authorization });
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql(user);
expect(authenticationResult.state).not.to.eql({ accessToken });
sinon.assert.calledOnce(callWithRequest);
});
it('fails if token cannot be generated during login attempt', async () => {
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
request.loginAttempt.returns(loginAttempt);
const authenticationError = new Error('Invalid credentials');
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'password', username: 'user', password: 'password' } })
.returns(Promise.reject(authenticationError));
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.notCalled(callWithRequest);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(authenticationError);
});
it('fails if user cannot be retrieved during login attempt', async () => {
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
request.loginAttempt.returns(loginAttempt);
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'password', username: 'user', password: 'password' } })
.returns(Promise.resolve({ access_token: 'foo', refresh_token: 'bar' }));
const authenticationError = new Error('Some error');
callWithRequest
.withArgs(request, 'shield.authenticate')
.returns(Promise.reject(authenticationError));
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledOnce(callWithRequest);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(authenticationError);
});
it('fails when header contains a rejected token', async () => {
const authorization = `Bearer foo`;
const request = requestFixture({ headers: { authorization } });
const authenticationError = new Error('Forbidden');
callWithRequest
.withArgs(request, 'shield.authenticate')
.returns(Promise.reject(authenticationError));
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledOnce(callWithRequest);
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(authenticationError);
});
it('fails when session contains a rejected token', async () => {
const accessToken = 'foo';
const request = requestFixture();
const authenticationError = new Error('Forbidden');
callWithRequest
.withArgs(request, 'shield.authenticate')
.returns(Promise.reject(authenticationError));
const authenticationResult = await provider.authenticate(request, { accessToken });
sinon.assert.calledOnce(callWithRequest);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(authenticationError);
});
it('fails if token refresh is rejected', async () => {
const request = requestFixture();
callWithRequest
.withArgs(sinon.match({ headers: { authorization: 'Bearer foo' } }), 'shield.authenticate')
.returns(Promise.reject({ body: { error: { reason: 'token expired' } } }));
const authenticationError = new Error('failed to refresh token');
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } })
.returns(Promise.reject(authenticationError));
const accessToken = 'foo';
const refreshToken = 'bar';
const authenticationResult = await provider.authenticate(request, { accessToken, refreshToken });
sinon.assert.calledOnce(callWithRequest);
sinon.assert.calledOnce(callWithInternalUser);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(authenticationError);
});
it('fails if new access token is rejected after successful refresh', async () => {
const request = requestFixture();
callWithRequest
.withArgs(sinon.match({ headers: { authorization: 'Bearer foo' } }), 'shield.authenticate')
.returns(Promise.reject({ body: { error: { reason: 'token expired' } } }));
callWithInternalUser
.withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } })
.returns(Promise.resolve({ access_token: 'newfoo', refresh_token: 'newbar' }));
const authenticationError = new Error('Some error');
callWithRequest
.withArgs(sinon.match({ headers: { authorization: 'Bearer newfoo' } }), 'shield.authenticate')
.returns(Promise.reject(authenticationError));
const accessToken = 'foo';
const refreshToken = 'bar';
const authenticationResult = await provider.authenticate(request, { accessToken, refreshToken });
sinon.assert.calledTwice(callWithRequest);
sinon.assert.calledOnce(callWithInternalUser);
expect(request.headers).to.not.have.property('authorization');
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.user).to.be.eql(undefined);
expect(authenticationResult.state).to.be.eql(undefined);
expect(authenticationResult.error).to.be.eql(authenticationError);
});
});
describe('`deauthenticate` method', () => {
let provider;
let callWithInternalUser;
beforeEach(() => {
callWithInternalUser = sinon.stub();
provider = new TokenAuthenticationProvider({
client: { callWithInternalUser },
log() {},
basePath: '/base-path'
});
});
describe('`deauthenticate` method', () => {
it('returns `notHandled` if state is not presented or does not include both access and refresh token.', async () => {
const request = requestFixture();
const accessToken = 'foo';
const refreshToken = 'bar';
let deauthenticateResult = await provider.deauthenticate(request);
expect(deauthenticateResult.notHandled()).to.be(true);
deauthenticateResult = await provider.deauthenticate(request, {});
expect(deauthenticateResult.notHandled()).to.be(true);
deauthenticateResult = await provider.deauthenticate(request, { accessToken });
expect(deauthenticateResult.notHandled()).to.be(true);
deauthenticateResult = await provider.deauthenticate(request, { refreshToken });
expect(deauthenticateResult.notHandled()).to.be(true);
sinon.assert.notCalled(callWithInternalUser);
deauthenticateResult = await provider.deauthenticate(request, { accessToken, refreshToken });
expect(deauthenticateResult.notHandled()).to.be(false);
});
it('fails if call to delete access token responds with an error', async () => {
const request = requestFixture();
const accessToken = 'foo';
const refreshToken = 'bar';
const failureReason = new Error('failed to delete token');
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { token: accessToken } })
.returns(Promise.reject(failureReason));
const authenticationResult = await provider.deauthenticate(request, { accessToken, refreshToken });
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.deleteAccessToken',
{ body: { token: accessToken } }
);
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.error).to.be(failureReason);
});
it('fails if call to delete refresh token responds with an error', async () => {
const request = requestFixture();
const accessToken = 'foo';
const refreshToken = 'bar';
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { token: accessToken } })
.returns({ created: true });
const failureReason = new Error('failed to delete token');
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { refresh_token: refreshToken } })
.returns(Promise.reject(failureReason));
const authenticationResult = await provider.deauthenticate(request, { accessToken, refreshToken });
sinon.assert.calledTwice(callWithInternalUser);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.deleteAccessToken',
{ body: { refresh_token: refreshToken } }
);
expect(authenticationResult.failed()).to.be(true);
expect(authenticationResult.error).to.be(failureReason);
});
it('redirects to /login if tokens are deleted successfully', async () => {
const request = requestFixture();
const accessToken = 'foo';
const refreshToken = 'bar';
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { token: accessToken } })
.returns({ created: true });
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { refresh_token: refreshToken } })
.returns({ created: true });
const authenticationResult = await provider.deauthenticate(request, { accessToken, refreshToken });
sinon.assert.calledTwice(callWithInternalUser);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.deleteAccessToken',
{ body: { token: accessToken } }
);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.deleteAccessToken',
{ body: { refresh_token: refreshToken } }
);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/base-path/login');
});
it('redirects to /login with optional search parameters if tokens are deleted successfully', async () => {
const request = requestFixture({ search: '?yep' });
const accessToken = 'foo';
const refreshToken = 'bar';
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { token: accessToken } })
.returns({ created: true });
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { refresh_token: refreshToken } })
.returns({ created: true });
const authenticationResult = await provider.deauthenticate(request, { accessToken, refreshToken });
sinon.assert.calledTwice(callWithInternalUser);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.deleteAccessToken',
{ body: { token: accessToken } }
);
sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.deleteAccessToken',
{ body: { refresh_token: refreshToken } }
);
expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/base-path/login?yep');
});
});
});
});

View file

@ -0,0 +1,354 @@
/*
* 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 { canRedirectRequest } from '../../can_redirect_request';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
/**
* Object that represents available provider options.
* @typedef {{
* basePath: string,
* client: Client,
* log: Function
* }} ProviderOptions
*/
/**
* Object that represents return value of internal header auth
* @typedef {{
* authenticationResult: AuthenticationResult,
* headerNotRecognized?: boolean
* }} HeaderAuthAttempt
*/
/**
* Checks the error returned by Elasticsearch as the result of `authenticate` call and returns `true` if request
* has been rejected because of expired token, otherwise returns `false`.
* @param {Object} err Error returned from Elasticsearch.
* @returns {boolean}
*/
function isAccessTokenExpiredError(err) {
return err.body
&& err.body.error
&& err.body.error.reason === 'token expired';
}
/**
* Provider that supports token-based request authentication.
*/
export class TokenAuthenticationProvider {
/**
* Server options that may be needed by authentication provider.
* @type {?ProviderOptions}
* @protected
*/
_options = null;
/**
* Instantiates TokenAuthenticationProvider.
* @param {ProviderOptions} options Provider options object.
*/
constructor(options) {
this._options = options;
}
/**
* Performs token-based request authentication
* @param {Hapi.Request} request HapiJS request instance.
* @param {Object} [state] Optional state object associated with the provider.
* @returns {Promise.<AuthenticationResult>}
*/
async authenticate(request, state) {
this._options.log(['debug', 'security', 'token'], `Trying to authenticate user request to ${request.url.path}.`);
// first try from login payload
let authenticationResult = await this._authenticateViaLoginAttempt(request);
// if there isn't a payload, try header-based token auth
if (authenticationResult.notHandled()) {
const {
authenticationResult: headerAuthResult,
headerNotRecognized,
} = await this._authenticateViaHeader(request);
if (headerNotRecognized) {
return headerAuthResult;
}
authenticationResult = headerAuthResult;
}
// if we still can't attempt auth, try authenticating via state (session token)
if (authenticationResult.notHandled() && state) {
authenticationResult = await this._authenticateViaState(request, state);
if (authenticationResult.failed() && isAccessTokenExpiredError(authenticationResult.error)) {
authenticationResult = await this._authenticateViaRefreshToken(request, state);
}
}
// finally, if authentication still can not be handled for this
// request/state combination, redirect to the login page if appropriate
if (authenticationResult.notHandled() && canRedirectRequest(request)) {
const nextURL = encodeURIComponent(`${request.getBasePath()}${request.url.path}`);
authenticationResult = AuthenticationResult.redirectTo(
`${this._options.basePath}/login?next=${nextURL}`
);
}
return authenticationResult;
}
/**
* Redirects user to the login page preserving query string parameters.
* @param {Hapi.Request} request HapiJS request instance.
* @param {Object} state State value previously stored by the provider.
* @returns {Promise.<DeauthenticationResult>}
*/
async deauthenticate(request, state) {
this._options.log(['debug', 'security', 'token'], `Trying to deauthenticate user via ${request.url.path}.`);
if (!state || !state.accessToken || !state.refreshToken) {
this._options.log(['debug', 'security', 'token'], 'There are no access and refresh tokens to invalidate.');
return DeauthenticationResult.notHandled();
}
this._options.log(['debug', 'security', 'token'], 'Token-based logout has been initiated by the user.');
try {
// First invalidate the access token.
const { created: deletedAccessToken } = await this._options.client.callWithInternalUser(
'shield.deleteAccessToken',
{ body: { token: state.accessToken } }
);
if (deletedAccessToken) {
this._options.log(['debug', 'security', 'token'], 'User access token has been successfully invalidated.');
} else {
this._options.log(['debug', 'security', 'token'], 'User access token was already invalidated.');
}
// Then invalidate the refresh token.
const { created: deletedRefreshToken } = await this._options.client.callWithInternalUser(
'shield.deleteAccessToken',
{ body: { refresh_token: state.refreshToken } }
);
if (deletedRefreshToken) {
this._options.log(['debug', 'security', 'token'], 'User refresh token has been successfully invalidated.');
} else {
this._options.log(['debug', 'security', 'token'], 'User refresh token was already invalidated.');
}
return DeauthenticationResult.redirectTo(
`${this._options.basePath}/login${request.url.search || ''}`
);
} catch(err) {
this._options.log(['debug', 'security', 'token'], `Failed invalidating user's access token: ${err.message}`);
return DeauthenticationResult.failed(err);
}
}
/**
* Validates whether request contains `Bearer ***` Authorization header and just passes it
* forward to Elasticsearch backend.
* @param {Hapi.Request} request HapiJS request instance.
* @returns {Promise.<HeaderAuthAttempt>}
* @private
*/
async _authenticateViaHeader(request) {
this._options.log(['debug', 'security', 'token'], 'Trying to authenticate via header.');
const authorization = request.headers.authorization;
if (!authorization) {
this._options.log(['debug', 'security', 'token'], 'Authorization header is not presented.');
return {
authenticationResult: AuthenticationResult.notHandled()
};
}
const authenticationSchema = authorization.split(/\s+/)[0];
if (authenticationSchema.toLowerCase() !== 'bearer') {
this._options.log(['debug', 'security', 'token'], `Unsupported authentication schema: ${authenticationSchema}`);
return {
authenticationResult: AuthenticationResult.notHandled(),
headerNotRecognized: true
};
}
try {
const user = await this._options.client.callWithRequest(request, 'shield.authenticate');
this._options.log(['debug', 'security', 'token'], 'Request has been authenticated via header.');
// We intentionally do not store anything in session state because token
// header auth can only be used on a request by request basis.
return {
authenticationResult: AuthenticationResult.succeeded(user)
};
} catch(err) {
this._options.log(['debug', 'security', 'token'], `Failed to authenticate request via header: ${err.message}`);
return {
authenticationResult: AuthenticationResult.failed(err)
};
}
}
/**
* @param {Hapi.Request} request HapiJS request instance.
* @returns {Promise.<AuthenticationResult>}
* @private
*/
async _authenticateViaLoginAttempt(request) {
this._options.log(['debug', 'security', 'token'], 'Trying to authenticate via login attempt.');
const credentials = request.loginAttempt().getCredentials();
if (!credentials) {
this._options.log(['debug', 'security', 'token'], 'Username and password not found in payload.');
return AuthenticationResult.notHandled();
}
try {
// First attempt to exchange login credentials for an access token
const { username, password } = credentials;
const {
access_token: accessToken,
refresh_token: refreshToken,
} = await this._options.client.callWithInternalUser(
'shield.getAccessToken',
{ body: { grant_type: 'password', username, password } }
);
this._options.log(['debug', 'security', 'token'], 'Get token API request to Elasticsearch successful');
// We validate that both access and refresh tokens exist in the response
// so other private methods in this class can rely on them both existing.
if (!accessToken) {
throw new Error('Unexpected response from get token API - no access token present');
}
if (!refreshToken) {
throw new Error('Unexpected response from get token API - no refresh token present');
}
// Then attempt to query for the user details using the new token
request.headers.authorization = `Bearer ${accessToken}`;
const user = await this._options.client.callWithRequest(request, 'shield.authenticate');
this._options.log(['debug', 'security', 'token'], 'User has been authenticated with new access token');
return AuthenticationResult.succeeded(user, { accessToken, refreshToken });
} catch(err) {
this._options.log(['debug', 'security', 'token'], `Failed to authenticate request via login attempt: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
return AuthenticationResult.failed(err);
}
}
/**
* Tries to extract authorization header from the state and adds it to the request before
* it's forwarded to Elasticsearch backend.
* @param {Hapi.Request} request HapiJS request instance.
* @param {Object} state State value previously stored by the provider.
* @returns {Promise.<AuthenticationResult>}
* @private
*/
async _authenticateViaState(request, { accessToken }) {
this._options.log(['debug', 'security', 'token'], 'Trying to authenticate via state.');
if (!accessToken) {
this._options.log(['debug', 'security', 'token'], 'Access token is not found in state.');
return AuthenticationResult.notHandled();
}
try {
request.headers.authorization = `Bearer ${accessToken}`;
const user = await this._options.client.callWithRequest(request, 'shield.authenticate');
this._options.log(['debug', 'security', 'token'], 'Request has been authenticated via state.');
return AuthenticationResult.succeeded(user);
} catch(err) {
this._options.log(['debug', 'security', 'token'], `Failed to authenticate request via state: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to crash if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
return AuthenticationResult.failed(err);
}
}
/**
* This method is only called when authentication via access token stored in the state failed because of expired
* token. So we should use refresh token, that is also stored in the state, to extend expired access token and
* authenticate user with it.
* @param {Hapi.Request} request HapiJS request instance.
* @param {Object} state State value previously stored by the provider.
* @returns {Promise.<AuthenticationResult>}
* @private
*/
async _authenticateViaRefreshToken(request, { refreshToken }) {
this._options.log(['debug', 'security', 'token'], 'Trying to refresh access token.');
if (!refreshToken) {
this._options.log(['debug', 'security', 'token'], 'Refresh token is not found in state.');
return AuthenticationResult.notHandled();
}
try {
// Token must be refreshed by the same user that obtained that token, the
// kibana system user.
const {
access_token: newAccessToken,
refresh_token: newRefreshToken
} = await this._options.client.callWithInternalUser(
'shield.getAccessToken',
{ body: { grant_type: 'refresh_token', refresh_token: refreshToken } }
);
this._options.log(['debug', 'security', 'token'], `Request to refresh token via Elasticsearch's get token API successful`);
// We validate that both access and refresh tokens exist in the response
// so other private methods in this class can rely on them both existing.
if (!newAccessToken) {
throw new Error('Unexpected response from get token API - no access token present');
}
if (!newRefreshToken) {
throw new Error('Unexpected response from get token API - no refresh token present');
}
request.headers.authorization = `Bearer ${newAccessToken}`;
const user = await this._options.client.callWithRequest(request, 'shield.authenticate');
this._options.log(['debug', 'security', 'token'], 'Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(
user,
{ accessToken: newAccessToken, refreshToken: newRefreshToken }
);
} catch (err) {
this._options.log(['debug', 'security', 'token'], `Failed to refresh access token: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
return AuthenticationResult.failed(err);
}
}
}

View file

@ -13,6 +13,7 @@ require('@kbn/test').runTestsCli([
require.resolve('../test/functional/config.js'),
require.resolve('../test/api_integration/config.js'),
require.resolve('../test/saml_api_integration/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'),
require.resolve('../test/saved_object_api_integration/security_and_spaces/config'),

View file

@ -0,0 +1,76 @@
/*
* 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 function ({ getService }) {
const supertest = getService('supertestWithoutAuth');
const es = getService('es');
async function createToken() {
const { access_token: accessToken } = await es.shield.getAccessToken({
body: {
grant_type: 'password',
username: 'elastic',
password: 'changeme',
}
});
return accessToken;
}
describe('header', () => {
it('accepts valid access token via authorization Bearer header', async () => {
const token = await createToken();
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'true')
.set('authorization', `Bearer ${token}`)
.expect(200);
});
it('accepts multiple requests for a single valid access token', async () => {
const token = await createToken();
// try it once
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'true')
.set('authorization', `Bearer ${token}`)
.expect(200);
// try it again to verity it isn't invalidated after a single request
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'true')
.set('authorization', `Bearer ${token}`)
.expect(200);
});
it('rejects invalid access token via authorization Bearer header', async () => {
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'true')
.set('authorization', 'Bearer notreal')
.expect(401);
});
it('rejects expired access token via authorization Bearer header', async function () {
this.timeout(40000);
const token = await createToken();
// Access token expiration is set to 15s for API integration tests.
// Let's wait for 20s to make sure token expires.
await new Promise(resolve => setTimeout(() => resolve(), 20000));
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'true')
.set('authorization', `Bearer ${token}`)
.expect(401);
});
});
}

View file

@ -0,0 +1,15 @@
/*
* 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 function ({ loadTestFile }) {
describe('token-based auth', function () {
this.tags('ciGroup6');
loadTestFile(require.resolve('./login'));
loadTestFile(require.resolve('./logout'));
loadTestFile(require.resolve('./header'));
loadTestFile(require.resolve('./session'));
});
}

View file

@ -0,0 +1,101 @@
/*
* 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 request from 'request';
export default function ({ getService }) {
const supertest = getService('supertestWithoutAuth');
function extractSessionCookie(response) {
const cookie = (response.headers['set-cookie'] || []).find(header => header.startsWith('sid='));
return cookie ? request.cookie(cookie) : undefined;
}
describe('login', () => {
it('accepts valid login credentials as 204 status', async () => {
await supertest
.post('/api/security/v1/login')
.set('kbn-xsrf', 'true')
.send({ username: 'elastic', password: 'changeme' })
.expect(204);
});
it('sets HttpOnly cookie with valid login', async () => {
const response = await supertest
.post('/api/security/v1/login')
.set('kbn-xsrf', 'true')
.send({ username: 'elastic', password: 'changeme' })
.expect(204);
const cookie = extractSessionCookie(response);
if (!cookie) {
throw new Error('No session cookie set');
}
if (!cookie.httpOnly) {
throw new Error('Session cookie is not marked as HttpOnly');
}
});
it('rejects without kbn-xsrf header as 400 status even if credentials are valid', async () => {
const response = await supertest
.post('/api/security/v1/login')
.send({ username: 'elastic', password: 'changeme' })
.expect(400);
if (extractSessionCookie(response)) {
throw new Error('Session cookie was set despite invalid login');
}
});
it('rejects without credentials as 400 status', async () => {
const response = await supertest
.post('/api/security/v1/login')
.set('kbn-xsrf', 'true')
.expect(400);
if (extractSessionCookie(response)) {
throw new Error('Session cookie was set despite invalid login');
}
});
it('rejects without password as 400 status', async () => {
const response = await supertest
.post('/api/security/v1/login')
.set('kbn-xsrf', 'true')
.send({ username: 'elastic' })
.expect(400);
if (extractSessionCookie(response)) {
throw new Error('Session cookie was set despite invalid login');
}
});
it('rejects without username as 400 status', async () => {
const response = await supertest
.post('/api/security/v1/login')
.set('kbn-xsrf', 'true')
.send({ password: 'changme' })
.expect(400);
if (extractSessionCookie(response)) {
throw new Error('Session cookie was set despite invalid login');
}
});
it('rejects invalid credentials as 401 status', async () => {
const response = await supertest
.post('/api/security/v1/login')
.set('kbn-xsrf', 'true')
.send({ username: 'elastic', password: 'notvalidpassword' })
.expect(401);
if (extractSessionCookie(response)) {
throw new Error('Session cookie was set despite invalid login');
}
});
});
}

View file

@ -0,0 +1,74 @@
/*
* 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 request from 'request';
export default function ({ getService }) {
const supertest = getService('supertestWithoutAuth');
function extractSessionCookie(response) {
const cookie = (response.headers['set-cookie'] || []).find(header => header.startsWith('sid='));
return cookie ? request.cookie(cookie) : undefined;
}
async function createSessionCookie() {
const response = await supertest
.post('/api/security/v1/login')
.set('kbn-xsrf', 'true')
.send({ username: 'elastic', password: 'changeme' });
const cookie = extractSessionCookie(response);
if (!cookie) {
throw new Error('No session cookie set after login');
}
return cookie;
}
describe('logout', () => {
it('redirects to login', async () => {
const cookie = await createSessionCookie();
await supertest
.get('/api/security/v1/logout')
.set('cookie', cookie.cookieString())
.expect(302)
.expect('location', '/login');
});
it('unsets the session cookie', async () => {
const cookie = await createSessionCookie();
const response = await supertest
.get('/api/security/v1/logout')
.set('cookie', cookie.cookieString());
const newCookie = extractSessionCookie(response);
if (!newCookie) {
throw new Error('Does not explicitly unset session cookie');
}
if (newCookie.value !== '') {
throw new Error('Session cookie was not set to empty');
}
});
it('invalidates the session cookie in case it is replayed', async () => {
const cookie = await createSessionCookie();
// destroy it
await supertest
.get('/api/security/v1/logout')
.set('cookie', cookie.cookieString());
// verify that the cookie no longer works
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'true')
.set('cookie', cookie.cookieString())
.expect(400);
});
});
}

View file

@ -0,0 +1,108 @@
/*
* 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 request from 'request';
export default function ({ getService }) {
const supertest = getService('supertestWithoutAuth');
function extractSessionCookie(response) {
const cookie = (response.headers['set-cookie'] || []).find(header => header.startsWith('sid='));
return cookie ? request.cookie(cookie) : undefined;
}
async function createSessionCookie() {
const response = await supertest
.post('/api/security/v1/login')
.set('kbn-xsrf', 'true')
.send({ username: 'elastic', password: 'changeme' });
const cookie = extractSessionCookie(response);
if (!cookie) {
throw new Error('No session cookie set after login');
}
return cookie;
}
describe('session', () => {
it('accepts valid session cookie', async () => {
const cookie = await createSessionCookie();
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'true')
.set('cookie', cookie.cookieString())
.expect(200);
});
it('accepts multiple requests for a single valid session cookie', async () => {
const cookie = await createSessionCookie();
// try it once
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'true')
.set('cookie', cookie.cookieString())
.expect(200);
// try it again to verity it isn't invalidated after a single request
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'true')
.set('cookie', cookie.cookieString())
.expect(200);
});
it('expired access token should be automatically refreshed', async function () {
this.timeout(40000);
const originalCookie = await createSessionCookie();
// Access token expiration is set to 15s for API integration tests.
// Let's wait for 20s to make sure token expires.
await new Promise(resolve => setTimeout(() => resolve(), 20000));
// This api call should succeed and automatically refresh token. Returned cookie will contain
// the new access and refresh token pair.
const response = await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'true')
.set('cookie', originalCookie.cookieString())
.expect(200);
const newCookie = extractSessionCookie(response);
if (!newCookie) {
throw new Error('No session cookie set after token refresh');
}
if (!newCookie.httpOnly) {
throw new Error('Session cookie is not marked as HttpOnly');
}
if (newCookie.value === originalCookie.value) {
throw new Error('Session cookie has not changed after refresh');
}
// 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', 'true')
.set('Cookie', originalCookie.cookieString())
.expect(400);
if (apiResponseWithExpiredToken.headers['set-cookie'] !== undefined) {
throw new Error('Request rejecting expired access token still set session cookie');
}
// The new cookie with fresh pair of access and refresh tokens should work.
await supertest
.get('/api/security/v1/me')
.set('kbn-xsrf', 'true')
.set('Cookie', newCookie.cookieString())
.expect(200);
});
});
}

View file

@ -0,0 +1,39 @@
/*
* 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 xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.js'));
return {
testFiles: [require.resolve('./auth')],
servers: xPackAPITestsConfig.get('servers'),
services: {
es: xPackAPITestsConfig.get('services.es'),
supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'),
},
junit: {
reportName: 'Token-auth API Integration Tests',
},
esTestCluster: {
...xPackAPITestsConfig.get('esTestCluster'),
serverArgs: [
...xPackAPITestsConfig.get('esTestCluster.serverArgs'),
'xpack.security.authc.token.enabled=true',
'xpack.security.authc.token.timeout=15s',
],
},
kbnTestServer: {
...xPackAPITestsConfig.get('kbnTestServer'),
serverArgs: [
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
'--optimize.enabled=false',
'--xpack.security.authProviders=[\"token\"]',
],
},
};
}