Skip to content

Commit

Permalink
[security] Support alternate auth providers for login (elastic#26979)
Browse files Browse the repository at this point in the history
Login is no longer coupled directly to our basic auth provider, so
alternative auth providers can now be used with our standard login flow.
The LoginAttempt request service is the mechanism for auth providers to
integrate with the login flow.
  • Loading branch information
epixa committed Dec 13, 2018
1 parent 18d8228 commit 22abb4e
Show file tree
Hide file tree
Showing 12 changed files with 302 additions and 94 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,26 @@
*/

import url from 'url';
import { stub } from 'sinon';
import { LoginAttempt } from '../../authentication/login_attempt';

export function requestFixture({
headers = { accept: 'something/html' },
auth = undefined,
params = undefined,
path = '/wat',
basePath = '',
search = '',
payload
} = {}) {
return {
raw: { req: { headers } },
auth,
headers,
params,
url: { path, search },
getBasePath: () => basePath,
loginAttempt: stub().returns(new LoginAttempt()),
query: search ? url.parse(search, { parseQueryString: true }).query : {},
payload,
state: { user: 'these are the contents of the user client cookie' }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function serverFixture() {
expose: stub(),
log: stub(),
route: stub(),
decorate: stub(),

info: {
protocol: 'protocol'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { serverFixture } from '../../__tests__/__fixtures__/server';
import { requestFixture } from '../../__tests__/__fixtures__/request';
import { Session } from '../session';
import { AuthScopeService } from '../../auth_scope_service';
import { LoginAttempt } from '../login_attempt';
import { initAuthenticator } from '../authenticator';
import * as ClientShield from '../../../../../../server/lib/get_client_shield';

Expand Down Expand Up @@ -149,40 +150,56 @@ describe('Authenticator', () => {
sinon.assert.calledWith(authorizationMode.initialize, request);
});

it('creates session whenever authentication provider returns state to store.', async () => {
it('creates session whenever authentication provider returns state for system API requests', async () => {
const user = { username: 'user' };
const systemAPIRequest = requestFixture({ headers: { authorization: 'Basic xxx' } });
const notSystemAPIRequest = requestFixture({ headers: { authorization: 'Basic yyy' } });
const request = requestFixture();
const loginAttempt = new LoginAttempt();
const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`;
loginAttempt.setCredentials('foo', 'bar');
request.loginAttempt.returns(loginAttempt);

server.plugins.kibana.systemApi.isSystemApiRequest
.withArgs(systemAPIRequest).returns(true)
.withArgs(notSystemAPIRequest).returns(false);
.withArgs(request).returns(true);

cluster.callWithRequest
.withArgs(systemAPIRequest).returns(Promise.resolve(user))
.withArgs(notSystemAPIRequest).returns(Promise.resolve(user));
.withArgs(request).returns(Promise.resolve(user));

const systemAPIAuthenticationResult = await authenticate(systemAPIRequest);
const systemAPIAuthenticationResult = await authenticate(request);
expect(systemAPIAuthenticationResult.succeeded()).to.be(true);
expect(systemAPIAuthenticationResult.user).to.be.eql({
...user,
scope: []
});
sinon.assert.calledOnce(session.set);
sinon.assert.calledWithExactly(session.set, systemAPIRequest, {
state: { authorization: systemAPIRequest.headers.authorization },
sinon.assert.calledWithExactly(session.set, request, {
state: { authorization },
provider: 'basic'
});
});

const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest);
it('creates session whenever authentication provider returns state for non-system API requests', async () => {
const user = { username: 'user' };
const request = requestFixture();
const loginAttempt = new LoginAttempt();
const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`;
loginAttempt.setCredentials('foo', 'bar');
request.loginAttempt.returns(loginAttempt);

server.plugins.kibana.systemApi.isSystemApiRequest
.withArgs(request).returns(false);

cluster.callWithRequest
.withArgs(request).returns(Promise.resolve(user));

const notSystemAPIAuthenticationResult = await authenticate(request);
expect(notSystemAPIAuthenticationResult.succeeded()).to.be(true);
expect(notSystemAPIAuthenticationResult.user).to.be.eql({
...user,
scope: []
});
sinon.assert.calledTwice(session.set);
sinon.assert.calledWithExactly(session.set, notSystemAPIRequest, {
state: { authorization: notSystemAPIRequest.headers.authorization },
sinon.assert.calledOnce(session.set);
sinon.assert.calledWithExactly(session.set, request, {
state: { authorization },
provider: 'basic'
});
});
Expand Down Expand Up @@ -263,50 +280,66 @@ describe('Authenticator', () => {
sinon.assert.notCalled(session.set);
});

it('replaces existing session with the one returned by authentication provider.', async () => {
it('replaces existing session with the one returned by authentication provider for system API requests', async () => {
const user = { username: 'user' };
const systemAPIRequest = requestFixture({ headers: { authorization: 'Basic xxx-new' } });
const notSystemAPIRequest = requestFixture({ headers: { authorization: 'Basic yyy-new' } });

session.get.withArgs(systemAPIRequest).returns(Promise.resolve({
state: { authorization: 'Basic xxx-old' },
provider: 'basic'
}));
const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`;
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('foo', 'bar');
request.loginAttempt.returns(loginAttempt);

session.get.withArgs(notSystemAPIRequest).returns(Promise.resolve({
state: { authorization: 'Basic yyy-old' },
session.get.withArgs(request).returns(Promise.resolve({
state: { authorization: 'Basic some-old-token' },
provider: 'basic'
}));

server.plugins.kibana.systemApi.isSystemApiRequest
.withArgs(systemAPIRequest).returns(true)
.withArgs(notSystemAPIRequest).returns(false);
.withArgs(request).returns(true);

cluster.callWithRequest
.withArgs(systemAPIRequest).returns(Promise.resolve(user))
.withArgs(notSystemAPIRequest).returns(Promise.resolve(user));
.withArgs(request).returns(Promise.resolve(user));

const systemAPIAuthenticationResult = await authenticate(systemAPIRequest);
expect(systemAPIAuthenticationResult.succeeded()).to.be(true);
expect(systemAPIAuthenticationResult.user).to.be.eql({
const authenticationResult = await authenticate(request);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql({
...user,
scope: []
});
sinon.assert.calledOnce(session.set);
sinon.assert.calledWithExactly(session.set, systemAPIRequest, {
state: { authorization: 'Basic xxx-new' },
sinon.assert.calledWithExactly(session.set, request, {
state: { authorization },
provider: 'basic'
});
});

const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest);
expect(notSystemAPIAuthenticationResult.succeeded()).to.be(true);
expect(notSystemAPIAuthenticationResult.user).to.be.eql({
it('replaces existing session with the one returned by authentication provider for non-system API requests', async () => {
const user = { username: 'user' };
const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`;
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('foo', 'bar');
request.loginAttempt.returns(loginAttempt);

session.get.withArgs(request).returns(Promise.resolve({
state: { authorization: 'Basic some-old-token' },
provider: 'basic'
}));

server.plugins.kibana.systemApi.isSystemApiRequest
.withArgs(request).returns(false);

cluster.callWithRequest
.withArgs(request).returns(Promise.resolve(user));

const authenticationResult = await authenticate(request);
expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql({
...user,
scope: []
});
sinon.assert.calledTwice(session.set);
sinon.assert.calledWithExactly(session.set, notSystemAPIRequest, {
state: { authorization: 'Basic yyy-new' },
sinon.assert.calledOnce(session.set);
sinon.assert.calledWithExactly(session.set, request, {
state: { authorization },
provider: 'basic'
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* 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 { LoginAttempt } from '../login_attempt';

describe('LoginAttempt', () => {
describe('getCredentials()', () => {
it('returns null by default', () => {
const attempt = new LoginAttempt();
expect(attempt.getCredentials()).to.be(null);
});

it('returns a credentials object after credentials are set', () => {
const attempt = new LoginAttempt();
attempt.setCredentials('foo', 'bar');
expect(attempt.getCredentials()).to.eql({ username: 'foo', password: 'bar' });
});
});

describe('setCredentials()', () => {
it('sets the credentials for this login attempt', () => {
const attempt = new LoginAttempt();
attempt.setCredentials('foo', 'bar');
expect(attempt.getCredentials()).to.eql({ username: 'foo', password: 'bar' });
});

it('throws if credentials have already been set', () => {
const attempt = new LoginAttempt();
attempt.setCredentials('foo', 'bar');
expect(() => attempt.setCredentials()).to.throwError('Credentials for login attempt have already been set');
});
});
});
10 changes: 10 additions & 0 deletions x-pack/plugins/security/server/lib/authentication/authenticator.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SAMLAuthenticationProvider } from './providers/saml';
import { AuthenticationResult } from './authentication_result';
import { DeauthenticationResult } from './deauthentication_result';
import { Session } from './session';
import { LoginAttempt } from './login_attempt';

// Mapping between provider key defined in the config and authentication
// provider class that can handle specific authentication mechanism.
Expand Down Expand Up @@ -282,6 +283,15 @@ export async function initAuthenticator(server, authorizationMode) {
const authScope = new AuthScopeService();
const authenticator = new Authenticator(server, authScope, session, authorizationMode);

const loginAttempts = new WeakMap();
server.decorate('request', 'loginAttempt', function () {
const request = this;
if (!loginAttempts.has(request)) {
loginAttempts.set(request, new LoginAttempt());
}
return loginAttempts.get(request);
});

server.expose('authenticate', (request) => authenticator.authenticate(request));
server.expose('deauthenticate', (request) => authenticator.deauthenticate(request));
server.expose('registerAuthScopeGetter', (scopeExtender) => authScope.registerGetter(scopeExtender));
Expand Down
48 changes: 48 additions & 0 deletions x-pack/plugins/security/server/lib/authentication/login_attempt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* 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.
*/

/**
* Object that represents login credentials
* @typedef {{
* username: string,
* password: string
* }} LoginCredentials
*/

/**
* A LoginAttempt represents a single attempt to provide login credentials.
* Once credentials are set, they cannot be changed.
*/
export class LoginAttempt {
/**
* Username and password for login
* @type {?LoginCredentials}
* @protected
*/
_credentials = null;

/**
* Gets the username and password for this login
* @returns {LoginCredentials}
*/
getCredentials() {
return this._credentials;
}

/**
* Sets the username and password for this login
* @param {string} username
* @param {string} password
* @returns {LoginCredentials}
*/
setCredentials(username, password) {
if (this._credentials) {
throw new Error('Credentials for login attempt have already been set');
}

this._credentials = { username, password };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import expect from 'expect.js';
import sinon from 'sinon';
import { requestFixture } from '../../../__tests__/__fixtures__/request';
import { LoginAttempt } from '../../login_attempt';
import { BasicAuthenticationProvider, BasicCredentials } from '../basic';

function generateAuthorizationHeader(username, password) {
Expand Down Expand Up @@ -63,6 +64,26 @@ describe('BasicAuthenticationProvider', () => {
expect(authenticationResult.notHandled()).to.be(true);
});

it('succeeds with valid login attempt and stores in session', async () => {
const user = { username: 'user' };
const authorization = generateAuthorizationHeader('user', 'password');
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
request.loginAttempt.returns(loginAttempt);

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({ authorization });
sinon.assert.calledOnce(callWithRequest);
});

it('succeeds if only `authorization` header is available.', async () => {
const request = BasicCredentials.decorateRequest(requestFixture(), 'user', 'password');
const user = { username: 'user' };
Expand All @@ -75,10 +96,22 @@ describe('BasicAuthenticationProvider', () => {

expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql(user);
expect(authenticationResult.state).to.be.eql({ authorization: request.headers.authorization });
sinon.assert.calledOnce(callWithRequest);
});

it('does not return session state for header-based auth', async () => {
const request = BasicCredentials.decorateRequest(requestFixture(), 'user', 'password');
const user = { username: 'user' };

callWithRequest
.withArgs(request, '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 user = { username: 'user' };
Expand Down Expand Up @@ -138,7 +171,7 @@ describe('BasicAuthenticationProvider', () => {

expect(authenticationResult.succeeded()).to.be(true);
expect(authenticationResult.user).to.be.eql(user);
expect(authenticationResult.state).to.be.eql({ authorization: request.headers.authorization });
expect(authenticationResult.state).not.to.eql({ authorization: request.headers.authorization });
sinon.assert.calledOnce(callWithRequest);
});
});
Expand Down
Loading

0 comments on commit 22abb4e

Please sign in to comment.