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

Make it possible to use Kibana anonymous authentication provider with ES anonymous access. #84074

Merged
merged 4 commits into from
Dec 2, 2020
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -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 === CredentialsType.ApiKey) {
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. It 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question What do you think about testing for a 401 response in the event that we've attempted to use native anonymous access against a cluster or node that isn't setup for it? We could log a more informative message that way, warning the administrator that their setup isn't configured correctly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, but it seems we can/should do that for all types of anonymous authentication (if apiKey isn't valid, if username or password isn't valid and if anonymous access isn't configured)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added for all cases.

? { 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(),
]),
}),
])
),

This comment was marked as resolved.

This comment was marked as resolved.

}
),
},
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