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

This commit is contained in:
Aleh Zasypkin 2020-12-02 09:32:49 +01:00 committed by GitHub
parent 717a66fc6c
commit 8981d0e9e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 202 additions and 50 deletions

View file

@ -29,32 +29,48 @@ 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:
credentials = 'elasticsearch_anonymous_user' as 'elasticsearch_anonymous_user';
break;
}
provider = new AnonymousAuthenticationProvider(mockOptions, { credentials });
});
describe('`login` method', () => {
@ -111,23 +127,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 () => {
@ -191,7 +213,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' } },
@ -237,9 +259,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);
});
});
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaRequest } from '../../../../../../src/core/server';
import { KibanaRequest, LegacyElasticsearchErrorHelpers } from '../../../../../../src/core/server';
import { AuthenticationResult } from '../authentication_result';
import { canRedirectRequest } from '../can_redirect_request';
import { DeauthenticationResult } from '../deauthentication_result';
@ -29,6 +29,11 @@ interface APIKeyCredentials {
apiKey: { id: string; key: string } | string;
}
/**
* Credentials that imply authentication based on the Elasticsearch native anonymous user.
*/
type ElasticsearchAnonymousUserCredentials = 'elasticsearch_anonymous_user';
/**
* Checks whether current request can initiate a new session.
* @param request Request instance.
@ -44,7 +49,10 @@ function canStartNewSession(request: KibanaRequest) {
* @param credentials
*/
function isAPIKeyCredentials(
credentials: UsernameAndPasswordCredentials | APIKeyCredentials
credentials:
| ElasticsearchAnonymousUserCredentials
| APIKeyCredentials
| UsernameAndPasswordCredentials
): credentials is APIKeyCredentials {
return !!(credentials as APIKeyCredentials).apiKey;
}
@ -59,14 +67,17 @@ 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>,
anonymousOptions?: Readonly<{
credentials?: Readonly<UsernameAndPasswordCredentials | APIKeyCredentials>;
credentials?: Readonly<
ElasticsearchAnonymousUserCredentials | UsernameAndPasswordCredentials | APIKeyCredentials
>;
}>
) {
super(options);
@ -76,7 +87,11 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider
throw new Error('Credentials must be specified');
}
if (isAPIKeyCredentials(credentials)) {
if (credentials === 'elasticsearch_anonymous_user') {
this.logger.debug(
'Anonymous requests will be authenticated using Elasticsearch native anonymous user.'
);
} else if (isAPIKeyCredentials(credentials)) {
this.logger.debug('Anonymous requests will be authenticated via API key.');
this.httpAuthorizationHeader = new HTTPAuthorizationHeader(
'ApiKey',
@ -155,7 +170,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;
}
/**
@ -164,7 +179,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(
@ -173,7 +190,23 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider
// Create session only if it doesn't exist yet, otherwise keep it unchanged.
return AuthenticationResult.succeeded(user, { authHeaders, state: state ? undefined : {} });
} catch (err) {
this.logger.debug(`Failed to authenticate request : ${err.message}`);
if (LegacyElasticsearchErrorHelpers.isNotAuthorizedError(err)) {
if (!this.httpAuthorizationHeader) {
this.logger.error(
`Failed to authenticate anonymous request using Elasticsearch reserved anonymous user. Anonymous access may not be properly configured in Elasticsearch: ${err.message}`
);
} else if (this.httpAuthorizationHeader.scheme.toLowerCase() === 'basic') {
this.logger.error(
`Failed to authenticate anonymous request using provided username/password credentials. The user with the provided username may not exist or the password is wrong: ${err.message}`
);
} else {
this.logger.error(
`Failed to authenticate anonymous request using provided API key. The key may not exist or expired: ${err.message}`
);
}
} else {
this.logger.error(`Failed to authenticate request : ${err.message}`);
}
return AuthenticationResult.failed(err);
}
}

View file

@ -902,8 +902,9 @@ describe('config schema', () => {
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation:
- [credentials.0.password]: expected value of type [string] but got [undefined]
- [credentials.1.apiKey]: expected at least one defined value but got [undefined]"
- [credentials.0]: expected value to equal [elasticsearch_anonymous_user]
- [credentials.1.password]: expected value of type [string] but got [undefined]
- [credentials.2.apiKey]: expected at least one defined value but got [undefined]"
`);
expect(() =>
@ -918,8 +919,9 @@ describe('config schema', () => {
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation:
- [credentials.0.username]: expected value of type [string] but got [undefined]
- [credentials.1.apiKey]: expected at least one defined value but got [undefined]"
- [credentials.0]: expected value to equal [elasticsearch_anonymous_user]
- [credentials.1.username]: expected value of type [string] but got [undefined]
- [credentials.2.apiKey]: expected at least one defined value but got [undefined]"
`);
});
@ -973,8 +975,9 @@ describe('config schema', () => {
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation:
- [credentials.0.username]: expected value of type [string] but got [undefined]
- [credentials.1.apiKey]: types that failed validation:
- [credentials.0]: expected value to equal [elasticsearch_anonymous_user]
- [credentials.1.username]: expected value of type [string] but got [undefined]
- [credentials.2.apiKey]: types that failed validation:
- [credentials.apiKey.0.key]: expected value of type [string] but got [undefined]
- [credentials.apiKey.1]: expected value of type [string] but got [Object]"
`);
@ -993,8 +996,9 @@ describe('config schema', () => {
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation:
- [credentials.0.username]: expected value of type [string] but got [undefined]
- [credentials.1.apiKey]: types that failed validation:
- [credentials.0]: expected value to equal [elasticsearch_anonymous_user]
- [credentials.1.username]: expected value of type [string] but got [undefined]
- [credentials.2.apiKey]: types that failed validation:
- [credentials.apiKey.0.id]: expected value of type [string] but got [undefined]
- [credentials.apiKey.1]: expected value of type [string] but got [Object]"
`);
@ -1073,6 +1077,40 @@ describe('config schema', () => {
`);
});
it('can be successfully validated with `elasticsearch_anonymous_user` credentials', () => {
expect(
ConfigSchema.validate({
authc: {
providers: {
anonymous: {
anonymous1: {
order: 0,
credentials: 'elasticsearch_anonymous_user',
},
},
},
},
}).authc.providers
).toMatchInlineSnapshot(`
Object {
"anonymous": Object {
"anonymous1": Object {
"credentials": "elasticsearch_anonymous_user",
"description": "Continue as Guest",
"enabled": true,
"hint": "For anonymous users",
"icon": "globe",
"order": 0,
"session": Object {
"idleTimeout": null,
},
"showInSelector": true,
},
},
}
`);
});
it('can be successfully validated with session config overrides', () => {
expect(
ConfigSchema.validate({

View file

@ -150,6 +150,7 @@ const providersConfigSchema = schema.object(
},
{
credentials: schema.oneOf([
schema.literal('elasticsearch_anonymous_user'),
schema.object({
username: schema.string(),
password: schema.string(),

View file

@ -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, credentials: 'elasticsearch_anonymous_user' } },
basic: { basic1: { order: 1 } },
})}`,
],
},
};
}

View file

@ -31,18 +31,24 @@ export default function ({ getService }: FtrProviderContext) {
expect(cookie.maxAge).to.be(0);
}
describe('Anonymous authentication', () => {
before(async () => {
await security.user.create('anonymous_user', {
password: 'changeme',
roles: [],
full_name: 'Guest',
});
});
const isElasticsearchAnonymousAccessEnabled = (config.get(
'esTestCluster.serverArgs'
) as string[]).some((setting) => setting.startsWith('xpack.security.authc.anonymous'));
after(async () => {
await security.user.delete('anonymous_user');
});
describe('Anonymous authentication', () => {
if (!isElasticsearchAnonymousAccessEnabled) {
before(async () => {
await security.user.create('anonymous_user', {
password: 'changeme',
roles: [],
full_name: 'Guest',
});
});
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);
@ -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
});