Expose session invalidation API. (#92376)

This commit is contained in:
Aleh Zasypkin 2021-03-24 09:54:08 +01:00 committed by GitHub
parent 585f6f2c5c
commit 3f3cc8ee35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1163 additions and 93 deletions

View 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[]

View 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

View file

@ -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[]

View file

@ -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

View file

@ -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

View file

@ -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();
});
});

View file

@ -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.

View file

@ -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);
}

View file

@ -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' });
});
});
});

View file

@ -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,
}),
},
});
}
);
}

View file

@ -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 => ({

View file

@ -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',
},
});
});
});
});

View file

@ -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');
}
}

View file

@ -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(),
}),

View file

@ -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' } },
],
},
},
},
});
});
});
});

View file

@ -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;
}
}

View file

@ -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'),

View file

@ -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)',
},
};
}

View file

@ -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'));
});
}

View file

@ -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);
});
});
}