-
Notifications
You must be signed in to change notification settings - Fork 8.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement AnonymousAuthenticationProvider.
- Loading branch information
Showing
4 changed files
with
357 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
336 changes: 336 additions & 0 deletions
336
x-pack/plugins/security/server/authentication/providers/anonymous.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters