Skip to content

Commit

Permalink
Make it possible to use Kibana anonymous authentication provider with…
Browse files Browse the repository at this point in the history
… ES anonymous access.
  • Loading branch information
azasypkin committed Nov 23, 2020
1 parent 6b9079c commit e22d8f7
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 76 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,32 +29,47 @@ function expectAuthenticateCall(
expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate');
}

enum CredentialsType {
Basic = 'Basic',
ApiKey = 'ApiKey',
None = 'ES native anonymous',
}

describe('AnonymousAuthenticationProvider', () => {
const user = mockAuthenticatedUser({
authentication_provider: { type: 'anonymous', name: 'anonymous1' },
});

for (const useBasicCredentials of [true, false]) {
describe(`with ${useBasicCredentials ? '`Basic`' : '`ApiKey`'} credentials`, () => {
for (const credentialsType of [
CredentialsType.Basic,
CredentialsType.ApiKey,
CredentialsType.None,
]) {
describe(`with ${credentialsType} credentials`, () => {
let provider: AnonymousAuthenticationProvider;
let mockOptions: ReturnType<typeof mockAuthenticationProviderOptions>;
let authorization: string;
beforeEach(() => {
mockOptions = mockAuthenticationProviderOptions({ name: 'anonymous1' });

provider = useBasicCredentials
? new AnonymousAuthenticationProvider(mockOptions, {
credentials: { username: 'user', password: 'pass' },
})
: new AnonymousAuthenticationProvider(mockOptions, {
credentials: { apiKey: 'some-apiKey' },
});
authorization = useBasicCredentials
? new HTTPAuthorizationHeader(
let credentials;
switch (credentialsType) {
case CredentialsType.Basic:
credentials = { username: 'user', password: 'pass' };
authorization = new HTTPAuthorizationHeader(
'Basic',
new BasicHTTPAuthorizationHeaderCredentials('user', 'pass').toString()
).toString()
: new HTTPAuthorizationHeader('ApiKey', 'some-apiKey').toString();
).toString();
break;
case CredentialsType.ApiKey:
credentials = { apiKey: 'some-apiKey' };
authorization = new HTTPAuthorizationHeader('ApiKey', 'some-apiKey').toString();
break;
default:
break;
}

provider = new AnonymousAuthenticationProvider(mockOptions, { credentials });
});

describe('`login` method', () => {
Expand Down Expand Up @@ -111,23 +126,29 @@ describe('AnonymousAuthenticationProvider', () => {
});

it('does not handle authentication via `authorization` header.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: { authorization } });
const originalAuthorizationHeader = 'Basic credentials';
const request = httpServerMock.createKibanaRequest({
headers: { authorization: originalAuthorizationHeader },
});
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.notHandled()
);

expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
expect(request.headers.authorization).toBe(authorization);
expect(request.headers.authorization).toBe(originalAuthorizationHeader);
});

it('does not handle authentication via `authorization` header even if state exists.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: { authorization } });
const originalAuthorizationHeader = 'Basic credentials';
const request = httpServerMock.createKibanaRequest({
headers: { authorization: originalAuthorizationHeader },
});
await expect(provider.authenticate(request, {})).resolves.toEqual(
AuthenticationResult.notHandled()
);

expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
expect(request.headers.authorization).toBe(authorization);
expect(request.headers.authorization).toBe(originalAuthorizationHeader);
});

it('succeeds for non-AJAX requests if state is available.', async () => {
Expand Down Expand Up @@ -191,7 +212,7 @@ describe('AnonymousAuthenticationProvider', () => {
expect(request.headers).not.toHaveProperty('authorization');
});

if (!useBasicCredentials) {
if (!credentialsType) {
it('properly handles extended format for the ApiKey credentials', async () => {
provider = new AnonymousAuthenticationProvider(mockOptions, {
credentials: { apiKey: { id: 'some-id', key: 'some-key' } },
Expand Down Expand Up @@ -237,9 +258,19 @@ describe('AnonymousAuthenticationProvider', () => {
});

it('`getHTTPAuthenticationScheme` method', () => {
expect(provider.getHTTPAuthenticationScheme()).toBe(
useBasicCredentials ? 'basic' : 'apikey'
);
let expectedAuthenticationScheme;
switch (credentialsType) {
case CredentialsType.Basic:
expectedAuthenticationScheme = 'basic';
break;
case CredentialsType.ApiKey:
expectedAuthenticationScheme = 'apikey';
break;
default:
expectedAuthenticationScheme = null;
break;
}
expect(provider.getHTTPAuthenticationScheme()).toBe(expectedAuthenticationScheme);
});
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,10 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider
static readonly type = 'anonymous';

/**
* Defines HTTP authorization header that should be used to authenticate request.
* Defines HTTP authorization header that should be used to authenticate request. Isn't defined if provider should
* rely on Elasticsearch native anonymous access.
*/
private readonly httpAuthorizationHeader: HTTPAuthorizationHeader;
private readonly httpAuthorizationHeader?: HTTPAuthorizationHeader;

constructor(
protected readonly options: Readonly<AuthenticationProviderOptions>,
Expand All @@ -72,29 +73,31 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider
super(options);

const credentials = anonymousOptions?.credentials;
if (!credentials) {
throw new Error('Credentials must be specified');
}

if (isAPIKeyCredentials(credentials)) {
this.logger.debug('Anonymous requests will be authenticated via API key.');
this.httpAuthorizationHeader = new HTTPAuthorizationHeader(
'ApiKey',
typeof credentials.apiKey === 'string'
? credentials.apiKey
: new BasicHTTPAuthorizationHeaderCredentials(
credentials.apiKey.id,
credentials.apiKey.key
).toString()
);
if (credentials) {
if (isAPIKeyCredentials(credentials)) {
this.logger.debug('Anonymous requests will be authenticated via API key.');
this.httpAuthorizationHeader = new HTTPAuthorizationHeader(
'ApiKey',
typeof credentials.apiKey === 'string'
? credentials.apiKey
: new BasicHTTPAuthorizationHeaderCredentials(
credentials.apiKey.id,
credentials.apiKey.key
).toString()
);
} else {
this.logger.debug('Anonymous requests will be authenticated via username and password.');
this.httpAuthorizationHeader = new HTTPAuthorizationHeader(
'Basic',
new BasicHTTPAuthorizationHeaderCredentials(
credentials.username,
credentials.password
).toString()
);
}
} else {
this.logger.debug('Anonymous requests will be authenticated via username and password.');
this.httpAuthorizationHeader = new HTTPAuthorizationHeader(
'Basic',
new BasicHTTPAuthorizationHeaderCredentials(
credentials.username,
credentials.password
).toString()
this.logger.debug(
'Anonymous requests will be authenticated using Elasticsearch native anonymous access.'
);
}
}
Expand Down Expand Up @@ -155,7 +158,7 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider
* HTTP header that provider attaches to all successfully authenticated requests to Elasticsearch.
*/
public getHTTPAuthenticationScheme() {
return this.httpAuthorizationHeader.scheme.toLowerCase();
return this.httpAuthorizationHeader?.scheme.toLowerCase() ?? null;
}

/**
Expand All @@ -164,7 +167,9 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider
* @param state State value previously stored by the provider.
*/
private async authenticateViaAuthorizationHeader(request: KibanaRequest, state?: unknown) {
const authHeaders = { authorization: this.httpAuthorizationHeader.toString() };
const authHeaders = this.httpAuthorizationHeader
? { authorization: this.httpAuthorizationHeader.toString() }
: ({} as Record<string, string>);
try {
const user = await this.getUser(request, authHeaders);
this.logger.debug(
Expand Down
26 changes: 19 additions & 7 deletions x-pack/plugins/security/server/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,15 +877,27 @@ describe('config schema', () => {
);
});

it('requires `credentials`', () => {
expect(() =>
it('does not require `credentials`', () => {
expect(
ConfigSchema.validate({
authc: { providers: { anonymous: { anonymous1: { order: 0 } } } },
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.anonymous.anonymous1.credentials]: expected at least one defined value but got [undefined]"
}).authc.providers
).toMatchInlineSnapshot(`
Object {
"anonymous": Object {
"anonymous1": Object {
"description": "Continue as Guest",
"enabled": true,
"hint": "For anonymous users",
"icon": "globe",
"order": 0,
"session": Object {
"idleTimeout": null,
},
"showInSelector": true,
},
},
}
`);
});

Expand Down
26 changes: 14 additions & 12 deletions x-pack/plugins/security/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,18 +149,20 @@ const providersConfigSchema = schema.object(
}),
},
{
credentials: schema.oneOf([
schema.object({
username: schema.string(),
password: schema.string(),
}),
schema.object({
apiKey: schema.oneOf([
schema.object({ id: schema.string(), key: schema.string() }),
schema.string(),
]),
}),
]),
credentials: schema.maybe(
schema.oneOf([
schema.object({
username: schema.string(),
password: schema.string(),
}),
schema.object({
apiKey: schema.oneOf([
schema.object({ id: schema.string(), key: schema.string() }),
schema.string(),
]),
}),
])
),
}
),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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 { FtrConfigProviderContext } from '@kbn/test/types/ftr';

export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const anonymousAPITestsConfig = await readConfigFile(require.resolve('./anonymous.config.ts'));
return {
...anonymousAPITestsConfig.getAll(),

junit: {
reportName: 'X-Pack Security API Integration Tests (Anonymous with ES anonymous access)',
},

esTestCluster: {
...anonymousAPITestsConfig.get('esTestCluster'),
serverArgs: [
...anonymousAPITestsConfig.get('esTestCluster.serverArgs'),
'xpack.security.authc.anonymous.username=anonymous_user',
'xpack.security.authc.anonymous.roles=anonymous_role',
],
},

kbnTestServer: {
...anonymousAPITestsConfig.get('kbnTestServer'),
serverArgs: [
...anonymousAPITestsConfig
.get('kbnTestServer.serverArgs')
.filter((arg: string) => !arg.startsWith('--xpack.security.authc.providers')),
`--xpack.security.authc.providers=${JSON.stringify({
anonymous: { anonymous1: { order: 0 } },
basic: { basic1: { order: 1 } },
})}`,
],
},
};
}
28 changes: 18 additions & 10 deletions x-pack/test/security_api_integration/tests/anonymous/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,24 @@ export default function ({ getService }: FtrProviderContext) {
expect(cookie.maxAge).to.be(0);
}

const isElasticsearchAnonymousAccessEnabled = (config.get(
'esTestCluster.serverArgs'
) as string[]).some((setting) => setting.startsWith('xpack.security.authc.anonymous'));

describe('Anonymous authentication', () => {
before(async () => {
await security.user.create('anonymous_user', {
password: 'changeme',
roles: [],
full_name: 'Guest',
if (!isElasticsearchAnonymousAccessEnabled) {
before(async () => {
await security.user.create('anonymous_user', {
password: 'changeme',
roles: [],
full_name: 'Guest',
});
});
});

after(async () => {
await security.user.delete('anonymous_user');
});
after(async () => {
await security.user.delete('anonymous_user');
});
}

it('should reject API requests if client is not authenticated', async () => {
await supertest.get('/internal/security/me').set('kbn-xsrf', 'xxx').expect(401);
Expand Down Expand Up @@ -97,7 +103,9 @@ export default function ({ getService }: FtrProviderContext) {

expect(user.username).to.eql('anonymous_user');
expect(user.authentication_provider).to.eql({ type: 'anonymous', name: 'anonymous1' });
expect(user.authentication_type).to.eql('realm');
expect(user.authentication_type).to.eql(
isElasticsearchAnonymousAccessEnabled ? 'anonymous' : 'realm'
);
// Do not assert on the `authentication_realm`, as the value differs for on-prem vs cloud
});

Expand Down

0 comments on commit e22d8f7

Please sign in to comment.