Make it possible to use Kibana anonymous authentication provider with ES anonymous access. (#84074)
This commit is contained in:
parent
717a66fc6c
commit
8981d0e9e2
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -150,6 +150,7 @@ const providersConfigSchema = schema.object(
|
|||
},
|
||||
{
|
||||
credentials: schema.oneOf([
|
||||
schema.literal('elasticsearch_anonymous_user'),
|
||||
schema.object({
|
||||
username: schema.string(),
|
||||
password: schema.string(),
|
||||
|
|
|
@ -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 } },
|
||||
})}`,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue