Skip to content

Commit

Permalink
Implement AnonymousAuthenticationProvider.
Browse files Browse the repository at this point in the history
  • Loading branch information
azasypkin committed Oct 13, 2020
1 parent 0dba45d commit 96a3d27
Show file tree
Hide file tree
Showing 4 changed files with 357 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { SecurityFeatureUsageServiceStart } from '../feature_usage';
import { SessionValue, Session } from '../session_management';

import {
AnonymousAuthenticationProvider,
AuthenticationProviderOptions,
AuthenticationProviderSpecificOptions,
BaseAuthenticationProvider,
Expand Down Expand Up @@ -85,6 +86,7 @@ const providerMap = new Map<
[TokenAuthenticationProvider.type, TokenAuthenticationProvider],
[OIDCAuthenticationProvider.type, OIDCAuthenticationProvider],
[PKIAuthenticationProvider.type, PKIAuthenticationProvider],
[AnonymousAuthenticationProvider.type, AnonymousAuthenticationProvider],
]);

/**
Expand Down
336 changes: 336 additions & 0 deletions x-pack/plugins/security/server/authentication/providers/anonymous.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
/*
* 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 Boom from 'boom';
import { createHash } from 'crypto';
import { KibanaRequest } from '../../../../../../src/core/server';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { HTTPAuthorizationHeader } from '../http_authentication';
import { Tokens, TokenPair } from '../tokens';
import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base';

/**
* The state supported by the provider.
*/
type ProviderState = {
/**
* Hash of the credentials used to create session.
*/
credentialsHash: string;
} & (({ type: 'bearer' } & TokenPair) | { type: 'apikey' });

/**
* Checks whether current request can initiate new session.
* @param request Request instance.
*/
function canStartNewSession(request: KibanaRequest) {
// We should try to establish new session only if request requires authentication.
return request.route.options.authRequired === true;
}

/**
* Provider that supports anonymous request authentication.
*/
export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider {
/**
* Type of the provider.
*/
static readonly type = 'anonymous';

/**
* Specifies credentials that should be used to authenticate anonymous user.
*/
private readonly credentials: Readonly<
{ username: string; password: string } | { apiKey: string }
>;

/**
* Hash of the credentials that current provider uses, used to check whether existing session was
* created using the same credentials.
*/
private readonly credentialsHash: string;

constructor(
protected readonly options: Readonly<AuthenticationProviderOptions>,
anonymousOptions?: Readonly<{
credentials?: { username: string; password: string } | { apiKey: string };
}>
) {
super(options);

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

this.credentials = anonymousOptions.credentials;
this.credentialsHash = createHash('sha3-256')
.update(JSON.stringify(anonymousOptions.credentials))
.digest('hex');
}

/**
* Performs initial login request.
* @param request Request instance.
* @param attempt Login attempt description.
* @param [state] Optional state object associated with the provider.
*/
public async login(request: KibanaRequest, attempt: unknown, state?: ProviderState | null) {
this.logger.debug('Trying to perform a login.');

if (state?.credentialsHash && state.credentialsHash !== this.credentialsHash) {
const message = `Provider is configured with different credentials that were used to create anonymous session.`;
this.logger.warn(message);
return AuthenticationResult.failed(Boom.unauthorized(message));
}

const authenticationResult = state
? await this.authenticateViaState(request, state)
: AuthenticationResult.notHandled();

if (
authenticationResult.notHandled() ||
(authenticationResult.failed() &&
Tokens.isAccessTokenExpiredError(authenticationResult.error))
) {
return await this.loginWithCredentials(request);
}

return authenticationResult;
}

/**
* Performs request authentication.
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: KibanaRequest, state?: ProviderState | null) {
this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`);

if (HTTPAuthorizationHeader.parseFromRequest(request) != null) {
this.logger.debug('Cannot authenticate requests with `Authorization` header.');
return AuthenticationResult.notHandled();
}

// It may happen that Kibana is re-configured to use different credentials for the same provider name,
// we should clear such session an log user out.
if (state?.credentialsHash && state.credentialsHash !== this.credentialsHash) {
const message = `Provider is configured with different credentials that were used to create anonymous session.`;
this.logger.warn(message);
return AuthenticationResult.failed(Boom.unauthorized(message));
}

let authenticationResult = AuthenticationResult.notHandled();
if (state) {
authenticationResult = await this.authenticateViaState(request, state);
if (
authenticationResult.failed() &&
Tokens.isAccessTokenExpiredError(authenticationResult.error)
) {
authenticationResult = await this.authenticateViaRefreshToken(request, state);
}
}

// If we couldn't authenticate by means of all methods above, let's try to log in automatically.
return authenticationResult.notHandled() && canStartNewSession(request)
? this.loginWithCredentials(request)
: authenticationResult;
}

/**
* Invalidates access and refresh token pair if they exist.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
public async logout(request: KibanaRequest, state?: ProviderState | null) {
this.logger.debug(`Trying to log user out via ${request.url.path}.`);

if (!state || state.type === 'apikey') {
this.logger.debug('There is no session to invalidate.');
return DeauthenticationResult.notHandled();
}

try {
await this.options.tokens.invalidate(state);
} catch (err) {
this.logger.debug(`Failed invalidating user's access token: ${err.message}`);
return DeauthenticationResult.failed(err);
}

return DeauthenticationResult.redirectTo(this.options.urls.loggedOut);
}

/**
* Returns HTTP authentication scheme (`Bearer` or `ApiKey`) that's used within `Authorization`
* HTTP header that provider attaches to all successfully authenticated requests to Elasticsearch.
*/
public getHTTPAuthenticationScheme() {
return 'username' in this.credentials ? 'bearer' : 'apikey';
}

/**
* Validates whether request payload contains `SAMLResponse` parameter that can be exchanged
* to a proper access token. If state is presented and includes request id then it means
* that login attempt has been initiated by Kibana itself and request id must be sent to
* Elasticsearch together with corresponding `SAMLResponse`. Not having state at this stage is
* indication of potential IdP initiated login, so we should send only `SAMLResponse` that
* Elasticsearch will decrypt and figure out on its own if it's a legit response from IdP
* initiated login.
*
* When login succeeds access token is stored in the state and user is redirected to the URL
* that was requested before SAML handshake or to default Kibana location in case of IdP
* initiated login.
* @param request Request instance.
*/
private async loginWithCredentials(request: KibanaRequest) {
return 'username' in this.credentials
? this.loginWithUsernameAndPassword(
request,
this.credentials.username,
this.credentials.password
)
: this.loginWithAPIKey(request, this.credentials.apiKey);
}

/**
* Tries to log user in with configured username and password.
* @param request Request instance.
* @param username Name of the Elasticsearch user that is used for "anonymous" access.
* @param password Password of the Elasticsearch user that is used for "anonymous" access.
*/
private async loginWithUsernameAndPassword(
request: KibanaRequest,
username: string,
password: string
) {
this.logger.debug('Trying to log in with configured anonymous username and password.');

try {
// First attempt to exchange login credentials for an access token
const {
access_token: accessToken,
refresh_token: refreshToken,
} = await this.options.client.callAsInternalUser('shield.getAccessToken', {
body: { grant_type: 'password', username, password },
});

this.logger.debug('Get token API request to Elasticsearch successful');

// Then attempt to query for the user details using the new token
const authHeaders = {
authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(),
};
const user = await this.getUser(request, authHeaders);

this.logger.debug('Login has been successfully performed.');
return AuthenticationResult.succeeded(user, {
authHeaders,
state: { type: 'bearer', accessToken, refreshToken },
});
} catch (err) {
this.logger.debug(`Failed to perform a login: ${err.message}`);
return AuthenticationResult.failed(err);
}
}

/**
* Tries to log user in with configured api key ID.
* @param request Request instance.
* @param apiKey Elasticsearch API key that is used for "anonymous" access.
*/
private async loginWithAPIKey(request: KibanaRequest, apiKey: string) {
this.logger.debug('Trying to log in with configured API key.');

try {
// Then attempt to query for the user details using the new token
const authHeaders = {
authorization: new HTTPAuthorizationHeader('ApiKey', apiKey).toString(),
};
const user = await this.getUser(request, authHeaders);

this.logger.debug('Login has been successfully performed.');
return AuthenticationResult.succeeded(user, { authHeaders, state: { type: 'apikey' } });
} catch (err) {
this.logger.debug(`Failed to perform a login: ${err.message}`);
return AuthenticationResult.failed(err);
}
}

/**
* Tries to extract access token from state and adds it to the request before it's
* forwarded to Elasticsearch backend.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(request: KibanaRequest, state: ProviderState) {
this.logger.debug(`Trying to authenticate via state with type ${state.type}.`);

const authHeaders = {
authorization: (state.type === 'bearer'
? new HTTPAuthorizationHeader('Bearer', state.accessToken)
: new HTTPAuthorizationHeader('ApiKey', (this.credentials as any).apiKey)
).toString(),
};

try {
const user = await this.getUser(request, authHeaders);
this.logger.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user, { authHeaders });
} catch (err) {
this.logger.debug(`Failed to authenticate request via state: ${err.message}`);
return AuthenticationResult.failed(err);
}
}

/**
* This method is only called when authentication via access token stored in the state failed because of expired
* token. So we should use refresh token, that is also stored in the state, to extend expired access token and
* authenticate user with it.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaRefreshToken(request: KibanaRequest, state: ProviderState) {
this.logger.debug('Trying to refresh access token.');

if (state.type !== 'bearer') {
this.logger.debug('Refresh token is not found in state.');
return AuthenticationResult.notHandled();
}

let refreshedTokenPair: TokenPair | null;
try {
refreshedTokenPair = await this.options.tokens.refresh(state.refreshToken);
} catch (err) {
return AuthenticationResult.failed(err);
}

// When user has neither valid access nor refresh token, the only way to resolve this issue is to log user again.
if (refreshedTokenPair === null) {
return this.loginWithCredentials(request);
}

try {
const authHeaders = {
authorization: new HTTPAuthorizationHeader(
'Bearer',
refreshedTokenPair.accessToken
).toString(),
};
const user = await this.getUser(request, authHeaders);

this.logger.debug('Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(user, {
authHeaders,
state: { ...state, ...refreshedTokenPair },
});
} catch (err) {
this.logger.debug(
`Failed to authenticate user using newly refreshed access token: ${err.message}`
);
return AuthenticationResult.failed(err);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
AuthenticationProviderOptions,
AuthenticationProviderSpecificOptions,
} from './base';
export { AnonymousAuthenticationProvider } from './anonymous';
export { BasicAuthenticationProvider } from './basic';
export { KerberosAuthenticationProvider } from './kerberos';
export { SAMLAuthenticationProvider, SAMLLogin } from './saml';
Expand Down
18 changes: 18 additions & 0 deletions x-pack/plugins/security/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,23 @@ const providersConfigSchema = schema.object(
schema.object({ ...getCommonProviderSchemaProperties(), realm: schema.string() })
)
),
anonymous: schema.maybe(
schema.recordOf(
schema.string(),
schema.object({
...getCommonProviderSchemaProperties(),
credentials: schema.oneOf([
schema.object({
username: schema.string(),
password: schema.string(),
}),
schema.object({
apiKey: schema.string(),
}),
]),
})
)
),
},
{
validate(config) {
Expand Down Expand Up @@ -182,6 +199,7 @@ export const ConfigSchema = schema.object({
oidc: undefined,
pki: undefined,
kerberos: undefined,
anonymous: undefined,
},
}),
oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })),
Expand Down

0 comments on commit 96a3d27

Please sign in to comment.