Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[security] Support alternate auth providers for login #26979

Merged
merged 5 commits into from
Dec 13, 2018
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -153,6 +154,10 @@ describe('Authenticator', () => {
const user = { username: 'user' };
const systemAPIRequest = requestFixture({ headers: { authorization: 'Basic xxx' } });
epixa marked this conversation as resolved.
Show resolved Hide resolved
const notSystemAPIRequest = requestFixture({ headers: { authorization: 'Basic yyy' } });
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('foo', 'bar');
systemAPIRequest.loginAttempt.returns(loginAttempt);
notSystemAPIRequest.loginAttempt.returns(loginAttempt);

server.plugins.kibana.systemApi.isSystemApiRequest
.withArgs(systemAPIRequest).returns(true)
Expand Down Expand Up @@ -265,8 +270,14 @@ describe('Authenticator', () => {

it('replaces existing session with the one returned by authentication provider.', async () => {
const user = { username: 'user' };
const systemAPIRequest = requestFixture({ headers: { authorization: 'Basic xxx-new' } });
const notSystemAPIRequest = requestFixture({ headers: { authorization: 'Basic yyy-new' } });
const oldAuth = Buffer.from('foo:notbar').toString('base64');
const newAuth = Buffer.from('foo:bar').toString('base64');
const systemAPIRequest = requestFixture({ headers: { authorization: `Basic ${oldAuth}` } });
const notSystemAPIRequest = requestFixture({ headers: { authorization: `Basic ${oldAuth}` } });
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('foo', 'bar');
epixa marked this conversation as resolved.
Show resolved Hide resolved
systemAPIRequest.loginAttempt.returns(loginAttempt);
notSystemAPIRequest.loginAttempt.returns(loginAttempt);

session.get.withArgs(systemAPIRequest).returns(Promise.resolve({
state: { authorization: 'Basic xxx-old' },
Expand Down Expand Up @@ -294,7 +305,7 @@ describe('Authenticator', () => {
});
sinon.assert.calledOnce(session.set);
sinon.assert.calledWithExactly(session.set, systemAPIRequest, {
state: { authorization: 'Basic xxx-new' },
state: { authorization: `Basic ${newAuth}` },
provider: 'basic'
});

Expand All @@ -306,7 +317,7 @@ describe('Authenticator', () => {
});
sinon.assert.calledTwice(session.set);
sinon.assert.calledWithExactly(session.set, notSystemAPIRequest, {
state: { authorization: 'Basic yyy-new' },
state: { authorization: `Basic ${newAuth}` },
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