Expose session invalidation API. (#92376)
This commit is contained in:
parent
585f6f2c5c
commit
3f3cc8ee35
9
docs/api/session-management.asciidoc
Normal file
9
docs/api/session-management.asciidoc
Normal file
|
@ -0,0 +1,9 @@
|
|||
[role="xpack"]
|
||||
[[session-management-api]]
|
||||
== User session management APIs
|
||||
|
||||
The following <<xpack-security-session-management, user session>> management APIs are available:
|
||||
|
||||
* <<session-management-api-invalidate, Invalidate user sessions API>> to invalidate user sessions
|
||||
|
||||
include::session-management/invalidate.asciidoc[]
|
114
docs/api/session-management/invalidate.asciidoc
Normal file
114
docs/api/session-management/invalidate.asciidoc
Normal file
|
@ -0,0 +1,114 @@
|
|||
[[session-management-api-invalidate]]
|
||||
=== Invalidate user sessions API
|
||||
++++
|
||||
<titleabbrev>Invalidate user sessions</titleabbrev>
|
||||
++++
|
||||
|
||||
experimental[] Invalidates user sessions that match provided query.
|
||||
|
||||
[[session-management-api-invalidate-prereqs]]
|
||||
==== Prerequisite
|
||||
|
||||
To use the invalidate user sessions API, you must be a `superuser`.
|
||||
|
||||
[[session-management-api-invalidate-request]]
|
||||
==== Request
|
||||
|
||||
`POST <kibana host>:<port>/api/security/session/_invalidate`
|
||||
|
||||
[role="child_attributes"]
|
||||
[[session-management-api-invalidate-request-body]]
|
||||
==== Request body
|
||||
|
||||
`match`::
|
||||
(Required, string) Specifies how {kib} determines which sessions to invalidate. Can either be `all` to invalidate all existing sessions, or `query` to only invalidate sessions that match the query specified in the additional `query` parameter.
|
||||
|
||||
`query`::
|
||||
(Optional, object) Specifies the query that {kib} uses to match the sessions to invalidate when the `match` parameter is set to `query`. You cannot use this parameter if `match` is set to `all`.
|
||||
+
|
||||
.Properties of `query`
|
||||
[%collapsible%open]
|
||||
=====
|
||||
`provider` :::
|
||||
(Required, object) Describes the <<authentication-security-settings, authentication providers>> for which to invalidate sessions.
|
||||
|
||||
`type` ::::
|
||||
(Required, string) The authentication provider `type`.
|
||||
|
||||
`name` ::::
|
||||
(Optional, string) The authentication provider `name`.
|
||||
|
||||
`username` :::
|
||||
(Optional, string) The username for which to invalidate sessions.
|
||||
=====
|
||||
|
||||
[[session-management-api-invalidate-response-body]]
|
||||
==== Response body
|
||||
|
||||
`total`::
|
||||
(number) The number of successfully invalidated sessions.
|
||||
|
||||
[[session-management-api-invalidate-response-codes]]
|
||||
==== Response codes
|
||||
|
||||
`200`::
|
||||
Indicates a successful call.
|
||||
|
||||
`403`::
|
||||
Indicates that the user may not be authorized to invalidate sessions for other users. Refer to <<session-management-api-invalidate-prereqs, prerequisites>>.
|
||||
|
||||
==== Examples
|
||||
|
||||
Invalidate all existing sessions:
|
||||
|
||||
[source,sh]
|
||||
--------------------------------------------------
|
||||
$ curl -X POST api/security/session/_invalidate
|
||||
{
|
||||
"match" : "all"
|
||||
}
|
||||
--------------------------------------------------
|
||||
// KIBANA
|
||||
|
||||
Invalidate sessions that were created by any <<saml, SAML authentication provider>>:
|
||||
|
||||
[source,sh]
|
||||
--------------------------------------------------
|
||||
$ curl -X POST api/security/session/_invalidate
|
||||
{
|
||||
"match" : "query",
|
||||
"query": {
|
||||
"provider" : { "type": "saml" }
|
||||
}
|
||||
}
|
||||
--------------------------------------------------
|
||||
// KIBANA
|
||||
|
||||
Invalidate sessions that were created by the <<saml, SAML authentication provider>> with the name `saml1`:
|
||||
|
||||
[source,sh]
|
||||
--------------------------------------------------
|
||||
$ curl -X POST api/security/session/_invalidate
|
||||
{
|
||||
"match" : "query",
|
||||
"query": {
|
||||
"provider" : { "type": "saml", "name": "saml1" }
|
||||
}
|
||||
}
|
||||
--------------------------------------------------
|
||||
// KIBANA
|
||||
|
||||
Invalidate sessions that were created by any <<oidc, OpenID Connect authentication provider>> for the user with the username `user@my-oidc-sso.com`:
|
||||
|
||||
[source,sh]
|
||||
--------------------------------------------------
|
||||
$ curl -X POST api/security/session/_invalidate
|
||||
{
|
||||
"match" : "query",
|
||||
"query": {
|
||||
"provider" : { "type": "oidc" },
|
||||
"username": "user@my-oidc-sso.com"
|
||||
}
|
||||
}
|
||||
--------------------------------------------------
|
||||
// KIBANA
|
|
@ -97,6 +97,7 @@ curl -X POST \
|
|||
include::{kib-repo-dir}/api/features.asciidoc[]
|
||||
include::{kib-repo-dir}/api/spaces-management.asciidoc[]
|
||||
include::{kib-repo-dir}/api/role-management.asciidoc[]
|
||||
include::{kib-repo-dir}/api/session-management.asciidoc[]
|
||||
include::{kib-repo-dir}/api/saved-objects.asciidoc[]
|
||||
include::{kib-repo-dir}/api/alerts.asciidoc[]
|
||||
include::{kib-repo-dir}/api/actions-and-connectors.asciidoc[]
|
||||
|
|
|
@ -397,6 +397,14 @@ NOTE: *Public URL* is available only when anonymous access is configured and you
|
|||
+
|
||||
For more information, refer to <<embedding, Embed {kib} content in a web page>>.
|
||||
|
||||
[float]
|
||||
[[anonymous-access-session]]
|
||||
===== Anonymous access session
|
||||
|
||||
{kib} maintains a separate <<xpack-security-session-management, session>> for every anonymous user, as it does for all other authentication mechanisms.
|
||||
|
||||
You can configure <<session-idle-timeout, session idle timeout>> and <<session-lifespan, session lifespan>> for anonymous sessions the same as you do for any other session with the exception that idle timeout is explicitly disabled for anonymous sessions by default. The global <<security-session-and-cookie-settings, `xpack.security.session.idleTimeout`>> setting doesn't affect anonymous sessions. To change the idle timeout for anonymous sessions, you must configure the provider-level <<anonymous-authentication-provider-settings, `xpack.security.authc.providers.anonymous.<provider-name>.session.idleTimeout`>> setting.
|
||||
|
||||
[[http-authentication]]
|
||||
==== HTTP authentication
|
||||
|
||||
|
|
|
@ -6,6 +6,8 @@ When you log in, {kib} creates a session that is used to authenticate subsequent
|
|||
|
||||
When your session expires, or you log out, {kib} will invalidate your cookie and remove session information from the index. {kib} also periodically invalidates and removes any expired sessions that weren't explicitly invalidated.
|
||||
|
||||
To manage user sessions programmatically, {kib} exposes <<session-management-api, session management APIs>>.
|
||||
|
||||
[[session-idle-timeout]]
|
||||
==== Session idle timeout
|
||||
|
||||
|
|
|
@ -585,8 +585,8 @@ describe('Authenticator', () => {
|
|||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.update).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.clear).toHaveBeenCalledWith(request);
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalledWith(request, { match: 'current' });
|
||||
});
|
||||
|
||||
it('clears session if provider asked to do so in `succeeded` result.', async () => {
|
||||
|
@ -605,8 +605,8 @@ describe('Authenticator', () => {
|
|||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.update).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.clear).toHaveBeenCalledWith(request);
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalledWith(request, { match: 'current' });
|
||||
});
|
||||
|
||||
it('clears session if provider asked to do so in `redirected` result.', async () => {
|
||||
|
@ -624,8 +624,8 @@ describe('Authenticator', () => {
|
|||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.update).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.clear).toHaveBeenCalledWith(request);
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalledWith(request, { match: 'current' });
|
||||
});
|
||||
|
||||
describe('with Access Agreement', () => {
|
||||
|
@ -1191,7 +1191,7 @@ describe('Authenticator', () => {
|
|||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.update).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.clear).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extends session for non-system API calls.', async () => {
|
||||
|
@ -1213,7 +1213,7 @@ describe('Authenticator', () => {
|
|||
expect(mockOptions.session.extend).toHaveBeenCalledWith(request, mockSessVal);
|
||||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.update).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.clear).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not touch session for system API calls if authentication fails with non-401 reason.', async () => {
|
||||
|
@ -1234,7 +1234,7 @@ describe('Authenticator', () => {
|
|||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.update).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.clear).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not touch session for non-system API calls if authentication fails with non-401 reason.', async () => {
|
||||
|
@ -1255,7 +1255,7 @@ describe('Authenticator', () => {
|
|||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.update).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.clear).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces existing session with the one returned by authentication provider for system API requests', async () => {
|
||||
|
@ -1281,7 +1281,7 @@ describe('Authenticator', () => {
|
|||
});
|
||||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.clear).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces existing session with the one returned by authentication provider for non-system API requests', async () => {
|
||||
|
@ -1307,7 +1307,7 @@ describe('Authenticator', () => {
|
|||
});
|
||||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.clear).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears session if provider failed to authenticate system API request with 401 with active session.', async () => {
|
||||
|
@ -1324,8 +1324,8 @@ describe('Authenticator', () => {
|
|||
AuthenticationResult.failed(Boom.unauthorized())
|
||||
);
|
||||
|
||||
expect(mockOptions.session.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.clear).toHaveBeenCalledWith(request);
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalledWith(request, { match: 'current' });
|
||||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.update).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
|
@ -1345,8 +1345,8 @@ describe('Authenticator', () => {
|
|||
AuthenticationResult.failed(Boom.unauthorized())
|
||||
);
|
||||
|
||||
expect(mockOptions.session.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.clear).toHaveBeenCalledWith(request);
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalledWith(request, { match: 'current' });
|
||||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.update).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
|
@ -1364,8 +1364,8 @@ describe('Authenticator', () => {
|
|||
AuthenticationResult.redirectTo('some-url', { state: null })
|
||||
);
|
||||
|
||||
expect(mockOptions.session.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.clear).toHaveBeenCalledWith(request);
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalledWith(request, { match: 'current' });
|
||||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.update).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
|
@ -1382,7 +1382,7 @@ describe('Authenticator', () => {
|
|||
AuthenticationResult.notHandled()
|
||||
);
|
||||
|
||||
expect(mockOptions.session.clear).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.update).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
|
@ -1399,7 +1399,7 @@ describe('Authenticator', () => {
|
|||
AuthenticationResult.notHandled()
|
||||
);
|
||||
|
||||
expect(mockOptions.session.clear).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.create).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.update).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.extend).not.toHaveBeenCalled();
|
||||
|
@ -1789,7 +1789,7 @@ describe('Authenticator', () => {
|
|||
DeauthenticationResult.notHandled()
|
||||
);
|
||||
|
||||
expect(mockOptions.session.clear).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears session and returns whatever authentication provider returns.', async () => {
|
||||
|
@ -1804,7 +1804,7 @@ describe('Authenticator', () => {
|
|||
);
|
||||
|
||||
expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.session.clear).toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('if session does not exist but provider name is valid, returns whatever authentication provider returns.', async () => {
|
||||
|
@ -1823,7 +1823,7 @@ describe('Authenticator', () => {
|
|||
|
||||
expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1);
|
||||
expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledWith(request, null);
|
||||
expect(mockOptions.session.clear).toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('if session does not exist and provider name is not available, returns whatever authentication provider returns.', async () => {
|
||||
|
@ -1840,7 +1840,7 @@ describe('Authenticator', () => {
|
|||
|
||||
expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1);
|
||||
expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledWith(request);
|
||||
expect(mockOptions.session.clear).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns `notHandled` if session does not exist and provider name is invalid', async () => {
|
||||
|
@ -1852,7 +1852,7 @@ describe('Authenticator', () => {
|
|||
);
|
||||
|
||||
expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled();
|
||||
expect(mockOptions.session.clear).toHaveBeenCalled();
|
||||
expect(mockOptions.session.invalidate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -396,7 +396,7 @@ export class Authenticator {
|
|||
sessionValue?.provider.name ??
|
||||
request.url.searchParams.get(LOGOUT_PROVIDER_QUERY_STRING_PARAMETER);
|
||||
if (suggestedProviderName) {
|
||||
await this.session.clear(request);
|
||||
await this.invalidateSessionValue(request);
|
||||
|
||||
// Provider name may be passed in a query param and sourced from the browser's local storage;
|
||||
// hence, we can't assume that this provider exists, so we have to check it.
|
||||
|
@ -522,7 +522,7 @@ export class Authenticator {
|
|||
this.logger.warn(
|
||||
`Attempted to retrieve session for the "${existingSessionValue.provider.type}/${existingSessionValue.provider.name}" provider, but it is not configured.`
|
||||
);
|
||||
await this.session.clear(request);
|
||||
await this.invalidateSessionValue(request);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -556,7 +556,7 @@ export class Authenticator {
|
|||
// attempt didn't fail.
|
||||
if (authenticationResult.shouldClearState()) {
|
||||
this.logger.debug('Authentication provider requested to invalidate existing session.');
|
||||
await this.session.clear(request);
|
||||
await this.invalidateSessionValue(request);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -570,7 +570,7 @@ export class Authenticator {
|
|||
if (authenticationResult.failed()) {
|
||||
if (ownsSession && getErrorStatusCode(authenticationResult.error) === 401) {
|
||||
this.logger.debug('Authentication attempt failed, existing session will be invalidated.');
|
||||
await this.session.clear(request);
|
||||
await this.invalidateSessionValue(request);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -608,17 +608,17 @@ export class Authenticator {
|
|||
this.logger.debug(
|
||||
'Authentication provider has changed, existing session will be invalidated.'
|
||||
);
|
||||
await this.session.clear(request);
|
||||
await this.invalidateSessionValue(request);
|
||||
existingSessionValue = null;
|
||||
} else if (sessionHasBeenAuthenticated) {
|
||||
this.logger.debug(
|
||||
'Session is authenticated, existing unauthenticated session will be invalidated.'
|
||||
);
|
||||
await this.session.clear(request);
|
||||
await this.invalidateSessionValue(request);
|
||||
existingSessionValue = null;
|
||||
} else if (usernameHasChanged) {
|
||||
this.logger.debug('Username has changed, existing session will be invalidated.');
|
||||
await this.session.clear(request);
|
||||
await this.invalidateSessionValue(request);
|
||||
existingSessionValue = null;
|
||||
}
|
||||
|
||||
|
@ -651,6 +651,14 @@ export class Authenticator {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates session value associated with the specified request.
|
||||
* @param request Request instance.
|
||||
*/
|
||||
private async invalidateSessionValue(request: KibanaRequest) {
|
||||
await this.session.invalidate(request, { match: 'current' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether request should be redirected to the Login Selector UI.
|
||||
* @param request Request instance.
|
||||
|
|
|
@ -8,8 +8,10 @@
|
|||
import type { RouteDefinitionParams } from '../';
|
||||
import { defineSessionExtendRoutes } from './extend';
|
||||
import { defineSessionInfoRoutes } from './info';
|
||||
import { defineInvalidateSessionsRoutes } from './invalidate';
|
||||
|
||||
export function defineSessionManagementRoutes(params: RouteDefinitionParams) {
|
||||
defineSessionInfoRoutes(params);
|
||||
defineSessionExtendRoutes(params);
|
||||
defineInvalidateSessionsRoutes(params);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ObjectType } from '@kbn/config-schema';
|
||||
import type { PublicMethodsOf } from '@kbn/utility-types';
|
||||
|
||||
import type { RequestHandler, RouteConfig } from '../../../../../../src/core/server';
|
||||
import { kibanaResponseFactory } from '../../../../../../src/core/server';
|
||||
import { httpServerMock } from '../../../../../../src/core/server/mocks';
|
||||
import type { Session } from '../../session_management';
|
||||
import { sessionMock } from '../../session_management/session.mock';
|
||||
import type { SecurityRequestHandlerContext, SecurityRouter } from '../../types';
|
||||
import { routeDefinitionParamsMock } from '../index.mock';
|
||||
import { defineInvalidateSessionsRoutes } from './invalidate';
|
||||
|
||||
describe('Invalidate sessions routes', () => {
|
||||
let router: jest.Mocked<SecurityRouter>;
|
||||
let session: jest.Mocked<PublicMethodsOf<Session>>;
|
||||
beforeEach(() => {
|
||||
const routeParamsMock = routeDefinitionParamsMock.create();
|
||||
router = routeParamsMock.router;
|
||||
|
||||
session = sessionMock.create();
|
||||
routeParamsMock.getSession.mockReturnValue(session);
|
||||
|
||||
defineInvalidateSessionsRoutes(routeParamsMock);
|
||||
});
|
||||
|
||||
describe('invalidate sessions', () => {
|
||||
let routeHandler: RequestHandler<any, any, any, SecurityRequestHandlerContext>;
|
||||
let routeConfig: RouteConfig<any, any, any, any>;
|
||||
beforeEach(() => {
|
||||
const [extendRouteConfig, extendRouteHandler] = router.post.mock.calls.find(
|
||||
([{ path }]) => path === '/api/security/session/_invalidate'
|
||||
)!;
|
||||
|
||||
routeConfig = extendRouteConfig;
|
||||
routeHandler = extendRouteHandler;
|
||||
});
|
||||
|
||||
it('correctly defines route.', () => {
|
||||
expect(routeConfig.options).toEqual({ tags: ['access:sessionManagement'] });
|
||||
|
||||
const bodySchema = (routeConfig.validate as any).body as ObjectType;
|
||||
expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[match]: expected at least one defined value but got [undefined]"`
|
||||
);
|
||||
expect(() => bodySchema.validate({ match: 'current' })).toThrowErrorMatchingInlineSnapshot(`
|
||||
"[match]: types that failed validation:
|
||||
- [match.0]: expected value to equal [all]
|
||||
- [match.1]: expected value to equal [query]"
|
||||
`);
|
||||
expect(() =>
|
||||
bodySchema.validate({ match: 'all', query: { provider: { type: 'basic' } } })
|
||||
).toThrowErrorMatchingInlineSnapshot(`"[query]: a value wasn't expected to be present"`);
|
||||
expect(() => bodySchema.validate({ match: 'query' })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[query.provider.type]: expected value of type [string] but got [undefined]"`
|
||||
);
|
||||
expect(() =>
|
||||
bodySchema.validate({ match: 'query', query: { username: 'user' } })
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[query.provider.type]: expected value of type [string] but got [undefined]"`
|
||||
);
|
||||
expect(() =>
|
||||
bodySchema.validate({
|
||||
match: 'query',
|
||||
query: { provider: { name: 'basic1' }, username: 'user' },
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[query.provider.type]: expected value of type [string] but got [undefined]"`
|
||||
);
|
||||
|
||||
expect(bodySchema.validate({ match: 'all' })).toEqual({ match: 'all' });
|
||||
expect(
|
||||
bodySchema.validate({ match: 'query', query: { provider: { type: 'basic' } } })
|
||||
).toEqual({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic' } },
|
||||
});
|
||||
expect(
|
||||
bodySchema.validate({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic', name: 'basic1' } },
|
||||
})
|
||||
).toEqual({ match: 'query', query: { provider: { type: 'basic', name: 'basic1' } } });
|
||||
expect(
|
||||
bodySchema.validate({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic' }, username: 'user' },
|
||||
})
|
||||
).toEqual({ match: 'query', query: { provider: { type: 'basic' }, username: 'user' } });
|
||||
expect(
|
||||
bodySchema.validate({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic', name: 'basic1' }, username: 'user' },
|
||||
})
|
||||
).toEqual({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic', name: 'basic1' }, username: 'user' },
|
||||
});
|
||||
});
|
||||
|
||||
it('properly constructs `query` match filter.', async () => {
|
||||
session.invalidate.mockResolvedValue(30);
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
body: {
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic', name: 'basic1' }, username: 'user' },
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
routeHandler(
|
||||
({} as unknown) as SecurityRequestHandlerContext,
|
||||
mockRequest,
|
||||
kibanaResponseFactory
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 200,
|
||||
options: { body: { total: 30 } },
|
||||
payload: { total: 30 },
|
||||
});
|
||||
|
||||
expect(session.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(session.invalidate).toHaveBeenCalledWith(mockRequest, {
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic', name: 'basic1' }, username: 'user' },
|
||||
});
|
||||
});
|
||||
|
||||
it('properly constructs `all` match filter.', async () => {
|
||||
session.invalidate.mockResolvedValue(30);
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest({ body: { match: 'all' } });
|
||||
await expect(
|
||||
routeHandler(
|
||||
({} as unknown) as SecurityRequestHandlerContext,
|
||||
mockRequest,
|
||||
kibanaResponseFactory
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 200,
|
||||
options: { body: { total: 30 } },
|
||||
payload: { total: 30 },
|
||||
});
|
||||
|
||||
expect(session.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(session.invalidate).toHaveBeenCalledWith(mockRequest, { match: 'all' });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import type { RouteDefinitionParams } from '..';
|
||||
|
||||
/**
|
||||
* Defines routes required for session invalidation.
|
||||
*/
|
||||
export function defineInvalidateSessionsRoutes({ router, getSession }: RouteDefinitionParams) {
|
||||
router.post(
|
||||
{
|
||||
path: '/api/security/session/_invalidate',
|
||||
validate: {
|
||||
body: schema.object({
|
||||
match: schema.oneOf([schema.literal('all'), schema.literal('query')]),
|
||||
query: schema.conditional(
|
||||
schema.siblingRef('match'),
|
||||
schema.literal('query'),
|
||||
schema.object({
|
||||
provider: schema.object({
|
||||
type: schema.string(),
|
||||
name: schema.maybe(schema.string()),
|
||||
}),
|
||||
username: schema.maybe(schema.string()),
|
||||
}),
|
||||
schema.never()
|
||||
),
|
||||
}),
|
||||
},
|
||||
options: { tags: ['access:sessionManagement'] },
|
||||
},
|
||||
async (_context, request, response) => {
|
||||
return response.ok({
|
||||
body: {
|
||||
total: await getSession().invalidate(request, {
|
||||
match: request.body.match,
|
||||
query: request.body.query,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
|
@ -18,7 +18,7 @@ export const sessionMock = {
|
|||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
extend: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
invalidate: jest.fn(),
|
||||
}),
|
||||
|
||||
createValue: (sessionValue: Partial<SessionValue> = {}): SessionValue => ({
|
||||
|
|
|
@ -103,7 +103,7 @@ describe('Session', () => {
|
|||
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull();
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears session value if session is expired because of lifespan', async () => {
|
||||
|
@ -122,7 +122,7 @@ describe('Session', () => {
|
|||
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull();
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears session value if session cookie does not have corresponding session index value', async () => {
|
||||
|
@ -151,7 +151,7 @@ describe('Session', () => {
|
|||
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull();
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears session value if session index value content cannot be decrypted because of wrong AAD', async () => {
|
||||
|
@ -170,7 +170,7 @@ describe('Session', () => {
|
|||
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull();
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns session value with decrypted content', async () => {
|
||||
|
@ -199,7 +199,7 @@ describe('Session', () => {
|
|||
username: 'some-user',
|
||||
});
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -279,7 +279,7 @@ describe('Session', () => {
|
|||
const mockRequest = httpServerMock.createKibanaRequest();
|
||||
await expect(session.update(mockRequest, sessionMock.createValue())).resolves.toBeNull();
|
||||
|
||||
expect(mockSessionIndex.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledWith(mockRequest);
|
||||
});
|
||||
|
@ -432,7 +432,7 @@ describe('Session', () => {
|
|||
})
|
||||
);
|
||||
|
||||
expect(mockSessionIndex.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
|
@ -485,7 +485,7 @@ describe('Session', () => {
|
|||
);
|
||||
expect(mockSessionIndex.update).not.toHaveBeenCalled();
|
||||
expect(mockSessionCookie.set).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
@ -563,7 +563,7 @@ describe('Session', () => {
|
|||
mockRequest,
|
||||
expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration })
|
||||
);
|
||||
expect(mockSessionIndex.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
@ -582,7 +582,7 @@ describe('Session', () => {
|
|||
)
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(mockSessionIndex.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledWith(mockRequest);
|
||||
});
|
||||
|
@ -625,7 +625,7 @@ describe('Session', () => {
|
|||
mockRequest,
|
||||
expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration })
|
||||
);
|
||||
expect(mockSessionIndex.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
@ -653,7 +653,7 @@ describe('Session', () => {
|
|||
mockRequest,
|
||||
expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration })
|
||||
);
|
||||
expect(mockSessionIndex.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
@ -696,7 +696,7 @@ describe('Session', () => {
|
|||
mockRequest,
|
||||
expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration })
|
||||
);
|
||||
expect(mockSessionIndex.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -764,7 +764,7 @@ describe('Session', () => {
|
|||
);
|
||||
}
|
||||
|
||||
expect(mockSessionIndex.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
|
@ -786,27 +786,98 @@ describe('Session', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('#clear', () => {
|
||||
it('does not clear anything if session does not exist', async () => {
|
||||
describe('#invalidate', () => {
|
||||
beforeEach(() => {
|
||||
mockSessionCookie.get.mockResolvedValue(sessionCookieMock.createValue());
|
||||
mockSessionIndex.invalidate.mockResolvedValue(10);
|
||||
});
|
||||
|
||||
it('[match=current] does not clear anything if session does not exist', async () => {
|
||||
mockSessionCookie.get.mockResolvedValue(null);
|
||||
|
||||
await session.clear(httpServerMock.createKibanaRequest());
|
||||
await session.invalidate(httpServerMock.createKibanaRequest(), { match: 'current' });
|
||||
|
||||
expect(mockSessionIndex.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears both session cookie and session index', async () => {
|
||||
it('[match=current] clears both session cookie and session index', async () => {
|
||||
mockSessionCookie.get.mockResolvedValue(sessionCookieMock.createValue());
|
||||
|
||||
const mockRequest = httpServerMock.createKibanaRequest();
|
||||
await session.clear(mockRequest);
|
||||
await session.invalidate(mockRequest, { match: 'current' });
|
||||
|
||||
expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.clear).toHaveBeenCalledWith('some-long-sid');
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledWith({
|
||||
match: 'sid',
|
||||
sid: 'some-long-sid',
|
||||
});
|
||||
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledWith(mockRequest);
|
||||
});
|
||||
|
||||
it('[match=all] clears all sessions even if current initiator request does not have a session', async () => {
|
||||
mockSessionCookie.get.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
session.invalidate(httpServerMock.createKibanaRequest(), { match: 'all' })
|
||||
).resolves.toBe(10);
|
||||
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledWith({ match: 'all' });
|
||||
});
|
||||
|
||||
it('[match=query] properly forwards filter with the provider type to the session index', async () => {
|
||||
await expect(
|
||||
session.invalidate(httpServerMock.createKibanaRequest(), {
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic' } },
|
||||
})
|
||||
).resolves.toBe(10);
|
||||
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledWith({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('[match=query] properly forwards filter with the provider type and provider name to the session index', async () => {
|
||||
await expect(
|
||||
session.invalidate(httpServerMock.createKibanaRequest(), {
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic', name: 'basic1' } },
|
||||
})
|
||||
).resolves.toBe(10);
|
||||
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledWith({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic', name: 'basic1' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('[match=query] properly forwards filter with the provider type, provider name, and username hash to the session index', async () => {
|
||||
await expect(
|
||||
session.invalidate(httpServerMock.createKibanaRequest(), {
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic', name: 'basic1' }, username: 'elastic' },
|
||||
})
|
||||
).resolves.toBe(10);
|
||||
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledWith({
|
||||
match: 'query',
|
||||
query: {
|
||||
provider: { type: 'basic', name: 'basic1' },
|
||||
usernameHash: 'eb28536c8ead72bf81a0a9226e38fc9bad81f5e07c2081bb801b2a5c8842924e',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -79,6 +79,18 @@ export interface SessionValueContentToEncrypt {
|
|||
state: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter provided for the `Session.invalidate` method that determines which session values should
|
||||
* be invalidated. It can have three possible types:
|
||||
* - `all` means that all existing active and inactive sessions should be invalidated.
|
||||
* - `current` means that session associated with the current request should be invalidated.
|
||||
* - `query` means that only sessions that match specified query should be invalidated.
|
||||
*/
|
||||
export type InvalidateSessionsFilter =
|
||||
| { match: 'all' }
|
||||
| { match: 'current' }
|
||||
| { match: 'query'; query: { provider: { type: string; name?: string }; username?: string } };
|
||||
|
||||
/**
|
||||
* The SIDs and AAD must be unpredictable to prevent guessing attacks, where an attacker is able to
|
||||
* guess or predict the ID of a valid session through statistical analysis techniques. That's why we
|
||||
|
@ -133,7 +145,7 @@ export class Session {
|
|||
(sessionCookieValue.lifespanExpiration && sessionCookieValue.lifespanExpiration < now)
|
||||
) {
|
||||
sessionLogger.debug('Session has expired and will be invalidated.');
|
||||
await this.clear(request);
|
||||
await this.invalidate(request, { match: 'current' });
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -155,7 +167,7 @@ export class Session {
|
|||
sessionLogger.warn(
|
||||
`Unable to decrypt session content, session will be invalidated: ${err.message}`
|
||||
);
|
||||
await this.clear(request);
|
||||
await this.invalidate(request, { match: 'current' });
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -194,7 +206,7 @@ export class Session {
|
|||
...publicSessionValue,
|
||||
...sessionExpirationInfo,
|
||||
sid,
|
||||
usernameHash: username && createHash('sha3-256').update(username).digest('hex'),
|
||||
usernameHash: username && Session.getUsernameHash(username),
|
||||
content: await this.crypto.encrypt(JSON.stringify({ username, state }), aad),
|
||||
});
|
||||
|
||||
|
@ -230,7 +242,7 @@ export class Session {
|
|||
...sessionValue.metadata.index,
|
||||
...publicSessionInfo,
|
||||
...sessionExpirationInfo,
|
||||
usernameHash: username && createHash('sha3-256').update(username).digest('hex'),
|
||||
usernameHash: username && Session.getUsernameHash(username),
|
||||
content: await this.crypto.encrypt(
|
||||
JSON.stringify({ username, state }),
|
||||
sessionCookieValue.aad
|
||||
|
@ -358,24 +370,53 @@ export class Session {
|
|||
}
|
||||
|
||||
/**
|
||||
* Clears session value for the specified request.
|
||||
* @param request Request instance to clear session value for.
|
||||
* Invalidates sessions that match the specified filter.
|
||||
* @param request Request instance initiated invalidation.
|
||||
* @param filter Filter that narrows down the list of the sessions that should be invalidated.
|
||||
*/
|
||||
async clear(request: KibanaRequest) {
|
||||
async invalidate(request: KibanaRequest, filter: InvalidateSessionsFilter) {
|
||||
// We don't require request to have the associated session, but nevertheless we still want to
|
||||
// log the SID if session is available.
|
||||
const sessionCookieValue = await this.options.sessionCookie.get(request);
|
||||
if (!sessionCookieValue) {
|
||||
return;
|
||||
const sessionLogger = this.getLoggerForSID(sessionCookieValue?.sid);
|
||||
|
||||
// We clear session cookie only when the current session should be invalidated since it's the
|
||||
// only case when this action is explicitly and unequivocally requested. This behavior doesn't
|
||||
// introduce any risk since even if the current session has been affected the session cookie
|
||||
// will be automatically invalidated as soon as client attempts to re-use it due to missing
|
||||
// underlying session index value.
|
||||
let invalidateIndexValueFilter;
|
||||
if (filter.match === 'current') {
|
||||
if (!sessionCookieValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionLogger.debug('Invalidating current session.');
|
||||
await this.options.sessionCookie.clear(request);
|
||||
invalidateIndexValueFilter = { match: 'sid' as 'sid', sid: sessionCookieValue.sid };
|
||||
} else if (filter.match === 'all') {
|
||||
sessionLogger.debug('Invalidating all sessions.');
|
||||
invalidateIndexValueFilter = filter;
|
||||
} else {
|
||||
sessionLogger.debug(
|
||||
`Invalidating sessions that match query: ${JSON.stringify(
|
||||
filter.query.username ? { ...filter.query, username: '[REDACTED]' } : filter.query
|
||||
)}.`
|
||||
);
|
||||
invalidateIndexValueFilter = filter.query.username
|
||||
? {
|
||||
...filter,
|
||||
query: {
|
||||
provider: filter.query.provider,
|
||||
usernameHash: Session.getUsernameHash(filter.query.username),
|
||||
},
|
||||
}
|
||||
: filter;
|
||||
}
|
||||
|
||||
const sessionLogger = this.getLoggerForSID(sessionCookieValue.sid);
|
||||
sessionLogger.debug('Invalidating session.');
|
||||
|
||||
await Promise.all([
|
||||
this.options.sessionCookie.clear(request),
|
||||
this.options.sessionIndex.clear(sessionCookieValue.sid),
|
||||
]);
|
||||
|
||||
sessionLogger.debug('Successfully invalidated session.');
|
||||
const invalidatedCount = await this.options.sessionIndex.invalidate(invalidateIndexValueFilter);
|
||||
sessionLogger.debug(`Successfully invalidated ${invalidatedCount} session(s).`);
|
||||
return invalidatedCount;
|
||||
}
|
||||
|
||||
private calculateExpiry(
|
||||
|
@ -414,9 +455,19 @@ export class Session {
|
|||
|
||||
/**
|
||||
* Creates logger scoped to a specified session ID.
|
||||
* @param sid Session ID to create logger for.
|
||||
* @param [sid] Session ID to create logger for.
|
||||
*/
|
||||
private getLoggerForSID(sid: string) {
|
||||
return this.options.logger.get(sid?.slice(-10));
|
||||
private getLoggerForSID(sid?: string) {
|
||||
return this.options.logger.get(sid?.slice(-10) ?? 'no_session');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a sha3-256 hash for the specified `username`. The hash is intended to be stored in
|
||||
* the session index to allow querying user specific sessions and don't expose the original
|
||||
* `username` at the same time.
|
||||
* @param username Username string to generate hash for.
|
||||
*/
|
||||
private static getUsernameHash(username: string) {
|
||||
return createHash('sha3-256').update(username).digest('hex');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ export const sessionIndexMock = {
|
|||
get: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
invalidate: jest.fn(),
|
||||
initialize: jest.fn(),
|
||||
cleanUp: jest.fn(),
|
||||
}),
|
||||
|
|
|
@ -162,7 +162,7 @@ describe('Session index', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('cleanUp', () => {
|
||||
describe('#cleanUp', () => {
|
||||
const now = 123456;
|
||||
beforeEach(() => {
|
||||
mockElasticsearchClient.deleteByQuery.mockResolvedValue(
|
||||
|
@ -797,18 +797,26 @@ describe('Session index', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('#clear', () => {
|
||||
it('throws if call to Elasticsearch fails', async () => {
|
||||
describe('#invalidate', () => {
|
||||
beforeEach(() => {
|
||||
mockElasticsearchClient.deleteByQuery.mockResolvedValue(
|
||||
securityMock.createApiResponse({ body: { deleted: 10 } })
|
||||
);
|
||||
});
|
||||
|
||||
it('[match=sid] throws if call to Elasticsearch fails', async () => {
|
||||
const failureReason = new errors.ResponseError(
|
||||
securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } }))
|
||||
);
|
||||
mockElasticsearchClient.delete.mockRejectedValue(failureReason);
|
||||
|
||||
await expect(sessionIndex.clear('some-long-sid')).rejects.toBe(failureReason);
|
||||
await expect(sessionIndex.invalidate({ match: 'sid', sid: 'some-long-sid' })).rejects.toBe(
|
||||
failureReason
|
||||
);
|
||||
});
|
||||
|
||||
it('properly removes session value from the index', async () => {
|
||||
await sessionIndex.clear('some-long-sid');
|
||||
it('[match=sid] properly removes session value from the index', async () => {
|
||||
await sessionIndex.invalidate({ match: 'sid', sid: 'some-long-sid' });
|
||||
|
||||
expect(mockElasticsearchClient.delete).toHaveBeenCalledTimes(1);
|
||||
expect(mockElasticsearchClient.delete).toHaveBeenCalledWith(
|
||||
|
@ -816,5 +824,125 @@ describe('Session index', () => {
|
|||
{ ignore: [404] }
|
||||
);
|
||||
});
|
||||
|
||||
it('[match=all] throws if call to Elasticsearch fails', async () => {
|
||||
const failureReason = new errors.ResponseError(
|
||||
securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } }))
|
||||
);
|
||||
mockElasticsearchClient.deleteByQuery.mockRejectedValue(failureReason);
|
||||
|
||||
await expect(sessionIndex.invalidate({ match: 'all' })).rejects.toBe(failureReason);
|
||||
});
|
||||
|
||||
it('[match=all] properly constructs query', async () => {
|
||||
await expect(sessionIndex.invalidate({ match: 'all' })).resolves.toBe(10);
|
||||
|
||||
expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
refresh: true,
|
||||
body: { query: { match_all: {} } },
|
||||
});
|
||||
});
|
||||
|
||||
it('[match=query] throws if call to Elasticsearch fails', async () => {
|
||||
const failureReason = new errors.ResponseError(
|
||||
securityMock.createApiResponse(securityMock.createApiResponse({ body: { type: 'Uh oh.' } }))
|
||||
);
|
||||
mockElasticsearchClient.deleteByQuery.mockRejectedValue(failureReason);
|
||||
|
||||
await expect(
|
||||
sessionIndex.invalidate({ match: 'query', query: { provider: { type: 'basic' } } })
|
||||
).rejects.toBe(failureReason);
|
||||
});
|
||||
|
||||
it('[match=query] when only provider type is specified', async () => {
|
||||
await expect(
|
||||
sessionIndex.invalidate({ match: 'query', query: { provider: { type: 'basic' } } })
|
||||
).resolves.toBe(10);
|
||||
|
||||
expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
refresh: true,
|
||||
body: { query: { bool: { must: [{ term: { 'provider.type': 'basic' } }] } } },
|
||||
});
|
||||
});
|
||||
|
||||
it('[match=query] when both provider type and provider name are specified', async () => {
|
||||
await expect(
|
||||
sessionIndex.invalidate({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic', name: 'basic1' } },
|
||||
})
|
||||
).resolves.toBe(10);
|
||||
|
||||
expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
refresh: true,
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { 'provider.type': 'basic' } },
|
||||
{ term: { 'provider.name': 'basic1' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('[match=query] when both provider type and username hash are specified', async () => {
|
||||
await expect(
|
||||
sessionIndex.invalidate({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic' }, usernameHash: 'some-hash' },
|
||||
})
|
||||
).resolves.toBe(10);
|
||||
|
||||
expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
refresh: true,
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { 'provider.type': 'basic' } },
|
||||
{ term: { usernameHash: 'some-hash' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('[match=query] when provider type, provider name, and username hash are specified', async () => {
|
||||
await expect(
|
||||
sessionIndex.invalidate({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic', name: 'basic1' }, usernameHash: 'some-hash' },
|
||||
})
|
||||
).resolves.toBe(10);
|
||||
|
||||
expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockElasticsearchClient.deleteByQuery).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
refresh: true,
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { 'provider.type': 'basic' } },
|
||||
{ term: { 'provider.name': 'basic1' } },
|
||||
{ term: { usernameHash: 'some-hash' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -17,6 +17,18 @@ export interface SessionIndexOptions {
|
|||
readonly logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter provided for the `SessionIndex.invalidate` method that determines which session index
|
||||
* values should be invalidated (removed from the index). It can have three possible types:
|
||||
* - `all` means that all existing active and inactive sessions should be invalidated.
|
||||
* - `sid` means that only session with the specified SID should be invalidated.
|
||||
* - `query` means that only sessions that match specified query should be invalidated.
|
||||
*/
|
||||
export type InvalidateSessionsFilter =
|
||||
| { match: 'all' }
|
||||
| { match: 'sid'; sid: string }
|
||||
| { match: 'query'; query: { provider: { type: string; name?: string }; usernameHash?: string } };
|
||||
|
||||
/**
|
||||
* Version of the current session index template.
|
||||
*/
|
||||
|
@ -237,19 +249,57 @@ export class SessionIndex {
|
|||
}
|
||||
|
||||
/**
|
||||
* Clears session value with the specified ID.
|
||||
* @param sid Session ID to clear.
|
||||
* Clears session value(s) determined by the specified filter.
|
||||
* @param filter Filter that narrows down the list of the session values that should be cleared.
|
||||
*/
|
||||
async clear(sid: string) {
|
||||
async invalidate(filter: InvalidateSessionsFilter) {
|
||||
if (filter.match === 'sid') {
|
||||
try {
|
||||
// We don't specify primary term and sequence number as delete should always take precedence
|
||||
// over any updates that could happen in the meantime.
|
||||
const { statusCode } = await this.options.elasticsearchClient.delete(
|
||||
{ id: filter.sid, index: this.indexName, refresh: 'wait_for' },
|
||||
{ ignore: [404] }
|
||||
);
|
||||
|
||||
// 404 means the session with such SID wasn't found and hence nothing was removed.
|
||||
return statusCode !== 404 ? 1 : 0;
|
||||
} catch (err) {
|
||||
this.options.logger.error(`Failed to clear session value: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// If filter is specified we should clear only session values that are matched by the filter.
|
||||
// Otherwise all session values should be cleared.
|
||||
let deleteQuery;
|
||||
if (filter.match === 'query') {
|
||||
deleteQuery = {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { 'provider.type': filter.query.provider.type } },
|
||||
...(filter.query.provider.name
|
||||
? [{ term: { 'provider.name': filter.query.provider.name } }]
|
||||
: []),
|
||||
...(filter.query.usernameHash
|
||||
? [{ term: { usernameHash: filter.query.usernameHash } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
deleteQuery = { match_all: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
// We don't specify primary term and sequence number as delete should always take precedence
|
||||
// over any updates that could happen in the meantime.
|
||||
await this.options.elasticsearchClient.delete(
|
||||
{ id: sid, index: this.indexName, refresh: 'wait_for' },
|
||||
{ ignore: [404] }
|
||||
);
|
||||
const { body: response } = await this.options.elasticsearchClient.deleteByQuery({
|
||||
index: this.indexName,
|
||||
refresh: true,
|
||||
body: { query: deleteQuery },
|
||||
});
|
||||
return response.deleted as number;
|
||||
} catch (err) {
|
||||
this.options.logger.error(`Failed to clear session value: ${err.message}`);
|
||||
this.options.logger.error(`Failed to clear session value(s): ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ const onlyNotInCoverageTests = [
|
|||
require.resolve('../test/plugin_api_integration/config.ts'),
|
||||
require.resolve('../test/security_api_integration/saml.config.ts'),
|
||||
require.resolve('../test/security_api_integration/session_idle.config.ts'),
|
||||
require.resolve('../test/security_api_integration/session_invalidate.config.ts'),
|
||||
require.resolve('../test/security_api_integration/session_lifespan.config.ts'),
|
||||
require.resolve('../test/security_api_integration/login_selector.config.ts'),
|
||||
require.resolve('../test/security_api_integration/audit.config.ts'),
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { resolve } from 'path';
|
||||
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
|
||||
import { services } from './services';
|
||||
|
||||
// the default export of config files must be a config provider
|
||||
// that returns an object with the projects config values
|
||||
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
||||
const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts'));
|
||||
|
||||
const kibanaPort = xPackAPITestsConfig.get('servers.kibana.port');
|
||||
const idpPath = resolve(__dirname, './fixtures/saml/idp_metadata.xml');
|
||||
|
||||
return {
|
||||
testFiles: [resolve(__dirname, './tests/session_invalidate')],
|
||||
services,
|
||||
servers: xPackAPITestsConfig.get('servers'),
|
||||
esTestCluster: {
|
||||
...xPackAPITestsConfig.get('esTestCluster'),
|
||||
serverArgs: [
|
||||
...xPackAPITestsConfig.get('esTestCluster.serverArgs'),
|
||||
'xpack.security.authc.token.enabled=true',
|
||||
'xpack.security.authc.realms.native.native1.order=0',
|
||||
'xpack.security.authc.realms.saml.saml1.order=1',
|
||||
`xpack.security.authc.realms.saml.saml1.idp.metadata.path=${idpPath}`,
|
||||
'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co/saml1',
|
||||
`xpack.security.authc.realms.saml.saml1.sp.entity_id=http://localhost:${kibanaPort}`,
|
||||
`xpack.security.authc.realms.saml.saml1.sp.logout=http://localhost:${kibanaPort}/logout`,
|
||||
`xpack.security.authc.realms.saml.saml1.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`,
|
||||
'xpack.security.authc.realms.saml.saml1.attributes.principal=urn:oid:0.0.7',
|
||||
],
|
||||
},
|
||||
|
||||
kbnTestServer: {
|
||||
...xPackAPITestsConfig.get('kbnTestServer'),
|
||||
serverArgs: [
|
||||
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
|
||||
`--xpack.security.authc.providers=${JSON.stringify({
|
||||
basic: { basic1: { order: 0 } },
|
||||
saml: { saml1: { order: 1, realm: 'saml1' } },
|
||||
})}`,
|
||||
],
|
||||
},
|
||||
|
||||
junit: {
|
||||
reportName: 'X-Pack Security API Integration Tests (Session Invalidate)',
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('security APIs - Session Invalidate', function () {
|
||||
this.tags('ciGroup6');
|
||||
|
||||
loadTestFile(require.resolve('./invalidate'));
|
||||
});
|
||||
}
|
|
@ -0,0 +1,350 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import request, { Cookie } from 'request';
|
||||
import expect from '@kbn/expect';
|
||||
import { adminTestUser } from '@kbn/test';
|
||||
import type { AuthenticationProvider } from '../../../../plugins/security/common/model';
|
||||
import { getSAMLRequestId, getSAMLResponse } from '../../fixtures/saml/saml_tools';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertestWithoutAuth');
|
||||
const es = getService('es');
|
||||
const security = getService('security');
|
||||
const esDeleteAllIndices = getService('esDeleteAllIndices');
|
||||
const config = getService('config');
|
||||
const randomness = getService('randomness');
|
||||
const kibanaServerConfig = config.get('servers.kibana');
|
||||
const notSuperuserTestUser = { username: 'test_user', password: 'changeme' };
|
||||
|
||||
async function checkSessionCookie(
|
||||
sessionCookie: Cookie,
|
||||
username: string,
|
||||
provider: AuthenticationProvider
|
||||
) {
|
||||
const apiResponse = await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', sessionCookie.cookieString())
|
||||
.expect(200);
|
||||
|
||||
expect(apiResponse.body.username).to.be(username);
|
||||
expect(apiResponse.body.authentication_provider).to.eql(provider);
|
||||
|
||||
return Array.isArray(apiResponse.headers['set-cookie'])
|
||||
? request.cookie(apiResponse.headers['set-cookie'][0])!
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function loginWithSAML() {
|
||||
const handshakeResponse = await supertest
|
||||
.post('/internal/security/login')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({ providerType: 'saml', providerName: 'saml1', currentURL: '' })
|
||||
.expect(200);
|
||||
|
||||
const authenticationResponse = await supertest
|
||||
.post('/api/security/saml/callback')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', request.cookie(handshakeResponse.headers['set-cookie'][0])!.cookieString())
|
||||
.send({
|
||||
SAMLResponse: await getSAMLResponse({
|
||||
destination: `http://localhost:${kibanaServerConfig.port}/api/security/saml/callback`,
|
||||
sessionIndex: String(randomness.naturalNumber()),
|
||||
inResponseTo: await getSAMLRequestId(handshakeResponse.body.location),
|
||||
}),
|
||||
})
|
||||
.expect(302);
|
||||
|
||||
const cookie = request.cookie(authenticationResponse.headers['set-cookie'][0])!;
|
||||
await checkSessionCookie(cookie, 'a@b.c', { type: 'saml', name: 'saml1' });
|
||||
return cookie;
|
||||
}
|
||||
|
||||
async function loginWithBasic(credentials: { username: string; password: string }) {
|
||||
const authenticationResponse = await supertest
|
||||
.post('/internal/security/login')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({
|
||||
providerType: 'basic',
|
||||
providerName: 'basic1',
|
||||
currentURL: '/',
|
||||
params: credentials,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const cookie = request.cookie(authenticationResponse.headers['set-cookie'][0])!;
|
||||
await checkSessionCookie(cookie, credentials.username, { type: 'basic', name: 'basic1' });
|
||||
return cookie;
|
||||
}
|
||||
|
||||
describe('Session Invalidate', () => {
|
||||
beforeEach(async () => {
|
||||
await es.cluster.health({ index: '.kibana_security_session*', wait_for_status: 'green' });
|
||||
await esDeleteAllIndices('.kibana_security_session*');
|
||||
await security.testUser.setRoles(['kibana_admin']);
|
||||
});
|
||||
|
||||
it('should be able to invalidate all sessions at once', async function () {
|
||||
const basicSessionCookie = await loginWithBasic(notSuperuserTestUser);
|
||||
const samlSessionCookie = await loginWithSAML();
|
||||
|
||||
// Invalidate all sessions and make sure neither of the sessions is active now.
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(adminTestUser.username, adminTestUser.password)
|
||||
.send({ match: 'all' })
|
||||
.expect(200, { total: 2 });
|
||||
await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', basicSessionCookie.cookieString())
|
||||
.expect(401);
|
||||
await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', samlSessionCookie.cookieString())
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should do nothing if specified provider type is not configured', async function () {
|
||||
const basicSessionCookie = await loginWithBasic(notSuperuserTestUser);
|
||||
const samlSessionCookie = await loginWithSAML();
|
||||
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(adminTestUser.username, adminTestUser.password)
|
||||
.send({ match: 'query', query: { provider: { type: 'oidc' } } })
|
||||
.expect(200, { total: 0 });
|
||||
await checkSessionCookie(basicSessionCookie, notSuperuserTestUser.username, {
|
||||
type: 'basic',
|
||||
name: 'basic1',
|
||||
});
|
||||
await checkSessionCookie(samlSessionCookie, 'a@b.c', { type: 'saml', name: 'saml1' });
|
||||
});
|
||||
|
||||
it('should be able to invalidate session only for a specific provider type', async function () {
|
||||
const basicSessionCookie = await loginWithBasic(notSuperuserTestUser);
|
||||
const samlSessionCookie = await loginWithSAML();
|
||||
|
||||
// Invalidate `basic` session and make sure that only `saml` session is still active.
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(adminTestUser.username, adminTestUser.password)
|
||||
.send({ match: 'query', query: { provider: { type: 'basic' } } })
|
||||
.expect(200, { total: 1 });
|
||||
await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', basicSessionCookie.cookieString())
|
||||
.expect(401);
|
||||
await checkSessionCookie(samlSessionCookie, 'a@b.c', { type: 'saml', name: 'saml1' });
|
||||
|
||||
// Invalidate `saml` session and make sure neither of the sessions is active now.
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(adminTestUser.username, adminTestUser.password)
|
||||
.send({ match: 'query', query: { provider: { type: 'saml' } } })
|
||||
.expect(200, { total: 1 });
|
||||
await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', samlSessionCookie.cookieString())
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should do nothing if specified provider name is not configured', async function () {
|
||||
const basicSessionCookie = await loginWithBasic(notSuperuserTestUser);
|
||||
const samlSessionCookie = await loginWithSAML();
|
||||
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(adminTestUser.username, adminTestUser.password)
|
||||
.send({ match: 'query', query: { provider: { type: 'basic', name: 'basic2' } } })
|
||||
.expect(200, { total: 0 });
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(adminTestUser.username, adminTestUser.password)
|
||||
.send({ match: 'query', query: { provider: { type: 'saml', name: 'saml2' } } })
|
||||
.expect(200, { total: 0 });
|
||||
await checkSessionCookie(basicSessionCookie, notSuperuserTestUser.username, {
|
||||
type: 'basic',
|
||||
name: 'basic1',
|
||||
});
|
||||
await checkSessionCookie(samlSessionCookie, 'a@b.c', { type: 'saml', name: 'saml1' });
|
||||
});
|
||||
|
||||
it('should be able to invalidate session only for a specific provider name', async function () {
|
||||
const basicSessionCookie = await loginWithBasic(notSuperuserTestUser);
|
||||
const samlSessionCookie = await loginWithSAML();
|
||||
|
||||
// Invalidate `saml1` session and make sure that only `basic1` session is still active.
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(adminTestUser.username, adminTestUser.password)
|
||||
.send({ match: 'query', query: { provider: { type: 'saml', name: 'saml1' } } })
|
||||
.expect(200, { total: 1 });
|
||||
await checkSessionCookie(basicSessionCookie, notSuperuserTestUser.username, {
|
||||
type: 'basic',
|
||||
name: 'basic1',
|
||||
});
|
||||
await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', samlSessionCookie.cookieString())
|
||||
.expect(401);
|
||||
|
||||
// Invalidate `basic1` session and make sure neither of the sessions is active now.
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(adminTestUser.username, adminTestUser.password)
|
||||
.send({ match: 'query', query: { provider: { type: 'basic', name: 'basic1' } } })
|
||||
.expect(200, { total: 1 });
|
||||
await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', basicSessionCookie.cookieString())
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should do nothing if specified username does not have session', async function () {
|
||||
const basicSessionCookie = await loginWithBasic(notSuperuserTestUser);
|
||||
const samlSessionCookie = await loginWithSAML();
|
||||
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(adminTestUser.username, adminTestUser.password)
|
||||
.send({
|
||||
match: 'query',
|
||||
query: {
|
||||
provider: { type: 'basic', name: 'basic1' },
|
||||
username: `_${notSuperuserTestUser.username}`,
|
||||
},
|
||||
})
|
||||
.expect(200, { total: 0 });
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(adminTestUser.username, adminTestUser.password)
|
||||
.send({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'saml', name: 'saml1' }, username: '_a@b.c' },
|
||||
})
|
||||
.expect(200, { total: 0 });
|
||||
await checkSessionCookie(basicSessionCookie, notSuperuserTestUser.username, {
|
||||
type: 'basic',
|
||||
name: 'basic1',
|
||||
});
|
||||
await checkSessionCookie(samlSessionCookie, 'a@b.c', { type: 'saml', name: 'saml1' });
|
||||
});
|
||||
|
||||
it('should be able to invalidate session only for a specific user', async function () {
|
||||
const basicSessionCookie = await loginWithBasic(notSuperuserTestUser);
|
||||
const samlSessionCookie = await loginWithSAML();
|
||||
|
||||
// Invalidate session for `test_user` and make sure that only session of `a@b.c` is still active.
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(adminTestUser.username, adminTestUser.password)
|
||||
.send({
|
||||
match: 'query',
|
||||
query: {
|
||||
provider: { type: 'basic', name: 'basic1' },
|
||||
username: notSuperuserTestUser.username,
|
||||
},
|
||||
})
|
||||
.expect(200, { total: 1 });
|
||||
await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', basicSessionCookie.cookieString())
|
||||
.expect(401);
|
||||
await checkSessionCookie(samlSessionCookie, 'a@b.c', { type: 'saml', name: 'saml1' });
|
||||
|
||||
// Invalidate session for `a@b.c` and make sure neither of the sessions is active now.
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(adminTestUser.username, adminTestUser.password)
|
||||
.send({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'saml', name: 'saml1' }, username: 'a@b.c' },
|
||||
})
|
||||
.expect(200, { total: 1 });
|
||||
await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', samlSessionCookie.cookieString())
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('only super users should be able to invalidate sessions', async function () {
|
||||
const basicSessionCookie = await loginWithBasic(notSuperuserTestUser);
|
||||
const samlSessionCookie = await loginWithSAML();
|
||||
|
||||
// User without a superuser role shouldn't be able to invalidate sessions.
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(notSuperuserTestUser.username, notSuperuserTestUser.password)
|
||||
.send({ match: 'all' })
|
||||
.expect(403);
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(notSuperuserTestUser.username, notSuperuserTestUser.password)
|
||||
.send({ match: 'query', query: { provider: { type: 'basic' } } })
|
||||
.expect(403);
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(notSuperuserTestUser.username, notSuperuserTestUser.password)
|
||||
.send({
|
||||
match: 'query',
|
||||
query: { provider: { type: 'basic' }, username: notSuperuserTestUser.username },
|
||||
})
|
||||
.expect(403);
|
||||
|
||||
await checkSessionCookie(basicSessionCookie, notSuperuserTestUser.username, {
|
||||
type: 'basic',
|
||||
name: 'basic1',
|
||||
});
|
||||
await checkSessionCookie(samlSessionCookie, 'a@b.c', { type: 'saml', name: 'saml1' });
|
||||
|
||||
// With superuser role, it should be possible now.
|
||||
await security.testUser.setRoles(['superuser']);
|
||||
|
||||
await supertest
|
||||
.post('/api/security/session/_invalidate')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.auth(notSuperuserTestUser.username, notSuperuserTestUser.password)
|
||||
.send({ match: 'all' })
|
||||
.expect(200, { total: 2 });
|
||||
await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', basicSessionCookie.cookieString())
|
||||
.expect(401);
|
||||
await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', samlSessionCookie.cookieString())
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue