-
Notifications
You must be signed in to change notification settings - Fork 8.3k
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
Prevent Kerberos and PKI providers from initiating a new session for unauthenticated XHR/API requests. #82817
Changes from all commits
0b6369e
1a7f5a1
cb9b05c
0670897
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,6 +13,7 @@ import { | |
import { AuthenticationResult } from '../authentication_result'; | ||
import { DeauthenticationResult } from '../deauthentication_result'; | ||
import { HTTPAuthorizationHeader } from '../http_authentication'; | ||
import { canRedirectRequest } from '../can_redirect_request'; | ||
import { Tokens, TokenPair } from '../tokens'; | ||
import { BaseAuthenticationProvider } from './base'; | ||
|
||
|
@@ -32,8 +33,9 @@ const WWWAuthenticateHeaderName = 'WWW-Authenticate'; | |
* @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; | ||
// We should try to establish new session only if request requires authentication and it's not an XHR request. | ||
// Technically we can authenticate XHR requests too, but we don't want these to create a new session unintentionally. | ||
return canRedirectRequest(request) && request.route.options.authRequired === true; | ||
} | ||
|
||
/** | ||
|
@@ -75,11 +77,8 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { | |
return AuthenticationResult.notHandled(); | ||
} | ||
|
||
let authenticationResult = authorizationHeader | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note: I'm not sure why we weren't checking the |
||
? await this.authenticateWithNegotiateScheme(request) | ||
: AuthenticationResult.notHandled(); | ||
|
||
if (state && authenticationResult.notHandled()) { | ||
let authenticationResult = AuthenticationResult.notHandled(); | ||
if (state) { | ||
authenticationResult = await this.authenticateViaState(request, state); | ||
if ( | ||
authenticationResult.failed() && | ||
|
@@ -89,11 +88,15 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { | |
} | ||
} | ||
|
||
// If we couldn't authenticate by means of all methods above, let's try to check if Elasticsearch can | ||
// start authentication mechanism negotiation, otherwise just return authentication result we have. | ||
return authenticationResult.notHandled() && canStartNewSession(request) | ||
? await this.authenticateViaSPNEGO(request, state) | ||
: authenticationResult; | ||
if (!authenticationResult.notHandled() || !canStartNewSession(request)) { | ||
return authenticationResult; | ||
} | ||
|
||
// If we couldn't authenticate by means of all methods above, let's check if we're already at the authentication | ||
// mechanism negotiation stage, otherwise check with Elasticsearch if we can start it. | ||
return authorizationHeader | ||
? await this.authenticateWithNegotiateScheme(request) | ||
: await this.authenticateViaSPNEGO(request, state); | ||
} | ||
|
||
/** | ||
|
@@ -264,12 +267,12 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { | |
return AuthenticationResult.failed(err); | ||
} | ||
|
||
// If refresh token is no longer valid, then we should clear session and renegotiate using SPNEGO. | ||
// If refresh token is no longer valid, let's try to renegotiate new tokens using SPNEGO. We | ||
// allow this because expired underlying token is an implementation detail and Kibana user | ||
// facing session is still valid. | ||
if (refreshedTokenPair === null) { | ||
this.logger.debug('Both access and refresh tokens are expired.'); | ||
return canStartNewSession(request) | ||
? this.authenticateViaSPNEGO(request, state) | ||
: AuthenticationResult.notHandled(); | ||
this.logger.debug('Both access and refresh tokens are expired. Re-authenticating...'); | ||
return this.authenticateViaSPNEGO(request, state); | ||
} | ||
|
||
try { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -295,6 +295,22 @@ describe('PKIAuthenticationProvider', () => { | |
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it('does not exchange peer certificate to access token for Ajax requests.', async () => { | ||
const request = httpServerMock.createKibanaRequest({ | ||
headers: { 'kbn-xsrf': 'xsrf' }, | ||
socket: getMockSocket({ | ||
authorized: true, | ||
peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), | ||
}), | ||
}); | ||
await expect(provider.authenticate(request)).resolves.toEqual( | ||
AuthenticationResult.notHandled() | ||
); | ||
|
||
expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); | ||
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => { | ||
const request = httpServerMock.createKibanaRequest({ | ||
socket: getMockSocket({ authorized: true }), | ||
|
@@ -383,14 +399,7 @@ describe('PKIAuthenticationProvider', () => { | |
}); | ||
|
||
it('gets a new access token even if existing token is expired.', async () => { | ||
const user = mockAuthenticatedUser(); | ||
const request = httpServerMock.createKibanaRequest({ | ||
socket: getMockSocket({ | ||
authorized: true, | ||
peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), | ||
}), | ||
}); | ||
const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; | ||
const user = mockAuthenticatedUser({ authentication_provider: { type: 'pki', name: 'pki' } }); | ||
|
||
const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); | ||
mockScopedClusterClient.callAsCurrentUser | ||
|
@@ -399,55 +408,102 @@ describe('PKIAuthenticationProvider', () => { | |
LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) | ||
) | ||
// In response to a call with a new token. | ||
.mockResolvedValueOnce(user) // In response to call with an expired token. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note: I'll remove this stupid setup in the scope of #80952 soon. |
||
.mockRejectedValueOnce( | ||
LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) | ||
) | ||
// In response to a call with a new token. | ||
.mockResolvedValueOnce(user) // In response to call with an expired token. | ||
.mockRejectedValueOnce( | ||
LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) | ||
) | ||
// In response to a call with a new token. | ||
.mockResolvedValueOnce(user); | ||
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); | ||
mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); | ||
|
||
await expect(provider.authenticate(request, state)).resolves.toEqual( | ||
AuthenticationResult.succeeded( | ||
{ ...user, authentication_provider: { type: 'pki', name: 'pki' } }, | ||
{ | ||
authHeaders: { authorization: 'Bearer access-token' }, | ||
state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, | ||
} | ||
) | ||
const nonAjaxRequest = httpServerMock.createKibanaRequest({ | ||
socket: getMockSocket({ | ||
authorized: true, | ||
peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), | ||
}), | ||
}); | ||
const nonAjaxState = { | ||
accessToken: 'existing-token', | ||
peerCertificateFingerprint256: '2A:7A:C2:DD', | ||
}; | ||
await expect(provider.authenticate(nonAjaxRequest, nonAjaxState)).resolves.toEqual( | ||
AuthenticationResult.succeeded(user, { | ||
authHeaders: { authorization: 'Bearer access-token' }, | ||
state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, | ||
}) | ||
); | ||
|
||
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); | ||
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { | ||
body: { | ||
x509_certificate_chain: [ | ||
'fingerprint:2A:7A:C2:DD:base64', | ||
'fingerprint:3B:8B:D3:EE:base64', | ||
], | ||
}, | ||
const ajaxRequest = httpServerMock.createKibanaRequest({ | ||
headers: { 'kbn-xsrf': 'xsrf' }, | ||
socket: getMockSocket({ | ||
authorized: true, | ||
peerCertificate: getMockPeerCertificate(['3A:7A:C2:DD', '3B:8B:D3:EE']), | ||
}), | ||
}); | ||
const ajaxState = { | ||
accessToken: 'existing-token', | ||
peerCertificateFingerprint256: '3A:7A:C2:DD', | ||
}; | ||
await expect(provider.authenticate(ajaxRequest, ajaxState)).resolves.toEqual( | ||
AuthenticationResult.succeeded(user, { | ||
authHeaders: { authorization: 'Bearer access-token' }, | ||
state: { accessToken: 'access-token', peerCertificateFingerprint256: '3A:7A:C2:DD' }, | ||
}) | ||
); | ||
|
||
expect(request.headers).not.toHaveProperty('authorization'); | ||
}); | ||
|
||
it('does not exchange peer certificate to a new access token even if existing token is expired and request does not require authentication.', async () => { | ||
const request = httpServerMock.createKibanaRequest({ | ||
const optionalAuthRequest = httpServerMock.createKibanaRequest({ | ||
routeAuthRequired: false, | ||
socket: getMockSocket({ | ||
authorized: true, | ||
peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), | ||
peerCertificate: getMockPeerCertificate(['4A:7A:C2:DD', '3B:8B:D3:EE']), | ||
}), | ||
}); | ||
const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; | ||
|
||
const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); | ||
mockScopedClusterClient.callAsCurrentUser.mockRejectedValueOnce( | ||
LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) | ||
const optionalAuthState = { | ||
accessToken: 'existing-token', | ||
peerCertificateFingerprint256: '4A:7A:C2:DD', | ||
}; | ||
await expect(provider.authenticate(optionalAuthRequest, optionalAuthState)).resolves.toEqual( | ||
AuthenticationResult.succeeded(user, { | ||
authHeaders: { authorization: 'Bearer access-token' }, | ||
state: { accessToken: 'access-token', peerCertificateFingerprint256: '4A:7A:C2:DD' }, | ||
}) | ||
); | ||
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); | ||
|
||
await expect(provider.authenticate(request, state)).resolves.toEqual( | ||
AuthenticationResult.notHandled() | ||
); | ||
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(3); | ||
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { | ||
body: { | ||
x509_certificate_chain: [ | ||
'fingerprint:2A:7A:C2:DD:base64', | ||
'fingerprint:3B:8B:D3:EE:base64', | ||
], | ||
}, | ||
}); | ||
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { | ||
body: { | ||
x509_certificate_chain: [ | ||
'fingerprint:3A:7A:C2:DD:base64', | ||
'fingerprint:3B:8B:D3:EE:base64', | ||
], | ||
}, | ||
}); | ||
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { | ||
body: { | ||
x509_certificate_chain: [ | ||
'fingerprint:4A:7A:C2:DD:base64', | ||
'fingerprint:3B:8B:D3:EE:base64', | ||
], | ||
}, | ||
}); | ||
|
||
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); | ||
expect(request.headers).not.toHaveProperty('authorization'); | ||
expect(nonAjaxRequest.headers).not.toHaveProperty('authorization'); | ||
expect(ajaxRequest.headers).not.toHaveProperty('authorization'); | ||
expect(optionalAuthRequest.headers).not.toHaveProperty('authorization'); | ||
}); | ||
|
||
it('fails with 401 if existing token is expired, but certificate is not present.', async () => { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note: just continue moving the remaining tests whenever I have chance.