[7.x] Add support for provider specific session timeout settings. (#82855)

This commit is contained in:
Aleh Zasypkin 2020-11-06 18:53:53 +01:00 committed by GitHub
parent 7552c17763
commit 0deed8eb29
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1370 additions and 267 deletions

View file

@ -92,8 +92,8 @@ The valid settings in the `xpack.security.authc.providers` namespace vary depend
`<provider-type>.<provider-name>.icon` {ess-icon}
| Custom icon for the provider entry displayed on the Login Selector UI.
| `xpack.security.authc.providers.`
`<provider-type>.<provider-name>.showInSelector` {ess-icon}
| `xpack.security.authc.providers.<provider-type>.`
`<provider-name>.showInSelector` {ess-icon}
| Flag that indicates if the provider should have an entry on the Login Selector UI. Setting this to `false` doesn't remove the provider from the authentication chain.
2+a|
@ -103,10 +103,31 @@ The valid settings in the `xpack.security.authc.providers` namespace vary depend
You are unable to set this setting to `false` for `basic` and `token` authentication providers.
============
| `xpack.security.authc.providers.`
`<provider-type>.<provider-name>.accessAgreement.message` {ess-icon}
| `xpack.security.authc.providers.<provider-type>.`
`<provider-name>.accessAgreement.message` {ess-icon}
| Access agreement text in Markdown format. For more information, refer to <<xpack-security-access-agreement>>.
| [[xpack-security-provider-session-idleTimeout]] `xpack.security.authc.providers.<provider-type>.`
`<provider-name>.session.idleTimeout` {ess-icon}
| Ensures that user sessions will expire after a period of inactivity. Setting this to `0` will prevent sessions from expiring because of inactivity. By default, this setting is equal to <<xpack-session-idleTimeout, `xpack.security.session.idleTimeout`>>.
2+a|
[TIP]
============
Use a string of `<count>[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w').
============
| [[xpack-security-provider-session-lifespan]] `xpack.security.authc.providers.<provider-type>.`
`<provider-name>.session.lifespan` {ess-icon}
| Ensures that user sessions will expire after the defined time period. This behavior is also known as an "absolute timeout". If
this is set to `0`, user sessions could stay active indefinitely. By default, this setting is equal to <<xpack-session-lifespan, `xpack.security.session.lifespan`>>.
2+a|
[TIP]
============
Use a string of `<count>[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w').
============
|===
[float]
@ -210,32 +231,32 @@ You can configure the following settings in the `kibana.yml` file.
|[[xpack-session-idleTimeout]] `xpack.security.session.idleTimeout` {ess-icon}
| Ensures that user sessions will expire after a period of inactivity. This and <<xpack-session-lifespan,`xpack.security.session.lifespan`>> are both
highly recommended. By default, this setting is not set.
highly recommended. You can also specify this setting for <<xpack-security-provider-session-idleTimeout, every provider separately>>. If this is _not_ set or set to `0`, then sessions will never expire due to inactivity. By default, this setting is not set.
2+a|
[TIP]
============
The format is a string of `<count>[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w').
Use a string of `<count>[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w').
============
|[[xpack-session-lifespan]] `xpack.security.session.lifespan` {ess-icon}
| Ensures that user sessions will expire after the defined time period. This behavior also known as an "absolute timeout". If
this is _not_ set, user sessions could stay active indefinitely. This and <<xpack-session-idleTimeout, `xpack.security.session.idleTimeout`>> are both highly
recommended. By default, this setting is not set.
| Ensures that user sessions will expire after the defined time period. This behavior is also known as an "absolute timeout". If
this is _not_ set or set to `0`, user sessions could stay active indefinitely. This and <<xpack-session-idleTimeout, `xpack.security.session.idleTimeout`>> are both highly
recommended. You can also specify this setting for <<xpack-security-provider-session-lifespan, every provider separately>>. By default, this setting is not set.
2+a|
[TIP]
============
The format is a string of `<count>[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w').
Use a string of `<count>[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w').
============
| `xpack.security.session.cleanupInterval`
| `xpack.security.session.cleanupInterval` {ess-icon}
| Sets the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default, this value is 1 hour. The minimum value is 10 seconds.
2+a|
[TIP]
============
The format is a string of `<count>[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w').
Use a string of `<count>[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w').
============
|===

View file

@ -15,7 +15,7 @@ export function mockAuthenticatedUser(user: Partial<AuthenticatedUser> = {}) {
enabled: true,
authentication_realm: { name: 'native1', type: 'native' },
lookup_realm: { name: 'native1', type: 'native' },
authentication_provider: 'basic1',
authentication_provider: { type: 'basic', name: 'basic1' },
authentication_type: 'realm',
...user,
};

View file

@ -12,6 +12,7 @@ describe('#canUserChangePassword', () => {
expect(
canUserChangePassword({
username: 'foo',
authentication_provider: { type: 'basic', name: 'basic1' },
authentication_realm: {
name: 'the realm name',
type: realm,
@ -25,6 +26,7 @@ describe('#canUserChangePassword', () => {
expect(
canUserChangePassword({
username: 'foo',
authentication_provider: { type: 'the provider type', name: 'does not matter' },
authentication_realm: {
name: 'the realm name',
type: 'does not matter',

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import type { AuthenticationProvider } from '../types';
import { User } from './user';
const REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE = ['reserved', 'native'];
@ -28,9 +29,9 @@ export interface AuthenticatedUser extends User {
lookup_realm: UserRealm;
/**
* Name of the Kibana authentication provider that used to authenticate user.
* The authentication provider that used to authenticate user.
*/
authentication_provider: string;
authentication_provider: AuthenticationProvider;
/**
* The AuthenticationType used by ES to authenticate the user.

View file

@ -10,14 +10,14 @@ import {
ILegacyClusterClient,
IBasePath,
} from '../../../../../src/core/server';
import { SecurityLicense } from '../../common/licensing';
import { AuthenticatedUser } from '../../common/model';
import { AuthenticationProvider } from '../../common/types';
import type { SecurityLicense } from '../../common/licensing';
import type { AuthenticatedUser } from '../../common/model';
import type { AuthenticationProvider } from '../../common/types';
import { SecurityAuditLogger, AuditServiceSetup, userLoginEvent } from '../audit';
import { ConfigType } from '../config';
import type { ConfigType } from '../config';
import { getErrorStatusCode } from '../errors';
import { SecurityFeatureUsageServiceStart } from '../feature_usage';
import { SessionValue, Session } from '../session_management';
import type { SecurityFeatureUsageServiceStart } from '../feature_usage';
import type { SessionValue, Session } from '../session_management';
import {
AuthenticationProviderOptions,
@ -261,7 +261,7 @@ export class Authenticator {
isLoginAttemptWithProviderName(attempt) && this.providers.has(attempt.provider.name)
? [[attempt.provider.name, this.providers.get(attempt.provider.name)!]]
: isLoginAttemptWithProviderType(attempt)
? [...this.providerIterator(existingSessionValue)].filter(
? [...this.providerIterator(existingSessionValue?.provider.name)].filter(
([, { type }]) => type === attempt.provider.type
)
: [];
@ -340,7 +340,9 @@ export class Authenticator {
);
}
for (const [providerName, provider] of this.providerIterator(existingSessionValue)) {
for (const [providerName, provider] of this.providerIterator(
existingSessionValue?.provider.name
)) {
// Check if current session has been set by this provider.
const ownsSession =
existingSessionValue?.provider.name === providerName &&
@ -397,7 +399,7 @@ export class Authenticator {
// active session already some providers can still properly respond to the 3rd-party logout
// request. For example SAML provider can process logout request encoded in `SAMLRequest`
// query string parameter.
for (const [, provider] of this.providerIterator(null)) {
for (const [, provider] of this.providerIterator()) {
const deauthenticationResult = await provider.logout(request);
if (!deauthenticationResult.notHandled()) {
return deauthenticationResult;
@ -475,22 +477,22 @@ export class Authenticator {
}
/**
* Returns provider iterator where providers are sorted in the order of priority (based on the session ownership).
* @param sessionValue Current session value.
* Returns provider iterator starting from the suggested provider if any.
* @param suggestedProviderName Optional name of the provider to return first.
*/
private *providerIterator(
sessionValue: SessionValue | null
suggestedProviderName?: string | null
): IterableIterator<[string, BaseAuthenticationProvider]> {
// If there is no session to predict which provider to use first, let's use the order
// providers are configured in. Otherwise return provider that owns session first, and only then the rest
// If there is no provider suggested or suggested provider isn't configured, let's use the order
// providers are configured in. Otherwise return suggested provider first, and only then the rest
// of providers.
if (!sessionValue) {
if (!suggestedProviderName || !this.providers.has(suggestedProviderName)) {
yield* this.providers;
} else {
yield [sessionValue.provider.name, this.providers.get(sessionValue.provider.name)!];
yield [suggestedProviderName, this.providers.get(suggestedProviderName)!];
for (const [providerName, provider] of this.providers) {
if (providerName !== sessionValue.provider.name) {
if (providerName !== suggestedProviderName) {
yield [providerName, provider];
}
}

View file

@ -114,7 +114,7 @@ export abstract class BaseAuthenticationProvider {
...(await this.options.client
.asScoped({ headers: { ...request.headers, ...authHeaders } })
.callAsCurrentUser('shield.authenticate')),
authentication_provider: this.options.name,
authentication_provider: { type: this.type, name: this.options.name },
} as AuthenticatedUser);
}
}

View file

@ -136,7 +136,10 @@ describe('HTTPAuthenticationProvider', () => {
});
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.succeeded({ ...user, authentication_provider: 'http' })
AuthenticationResult.succeeded({
...user,
authentication_provider: { type: 'http', name: 'http' },
})
);
expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } });

View file

@ -128,7 +128,7 @@ describe('KerberosAuthenticationProvider', () => {
await expect(operation(request)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'kerberos' },
{ ...user, authentication_provider: { type: 'kerberos', name: 'kerberos' } },
{
authHeaders: { authorization: 'Bearer some-token' },
state: { accessToken: 'some-token', refreshToken: 'some-refresh-token' },
@ -164,7 +164,7 @@ describe('KerberosAuthenticationProvider', () => {
await expect(operation(request)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'kerberos' },
{ ...user, authentication_provider: { type: 'kerberos', name: 'kerberos' } },
{
authHeaders: { authorization: 'Bearer some-token' },
authResponseHeaders: { 'WWW-Authenticate': 'Negotiate response-token' },
@ -361,7 +361,7 @@ describe('KerberosAuthenticationProvider', () => {
await expect(provider.authenticate(request, tokenPair)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'kerberos' },
{ ...user, authentication_provider: { type: 'kerberos', name: 'kerberos' } },
{ authHeaders: { authorization } }
)
);
@ -401,7 +401,7 @@ describe('KerberosAuthenticationProvider', () => {
await expect(provider.authenticate(request, tokenPair)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'kerberos' },
{ ...user, authentication_provider: { type: 'kerberos', name: 'kerberos' } },
{
authHeaders: { authorization: 'Bearer newfoo' },
state: { accessToken: 'newfoo', refreshToken: 'newbar' },

View file

@ -29,7 +29,7 @@ describe('OIDCAuthenticationProvider', () => {
mockOptions = mockAuthenticationProviderOptions({ name: 'oidc' });
mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
mockUser = mockAuthenticatedUser({ authentication_provider: 'oidc' });
mockUser = mockAuthenticatedUser({ authentication_provider: { type: 'oidc', name: 'oidc' } });
mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method) => {
if (method === 'shield.authenticate') {
return mockUser;

View file

@ -127,7 +127,7 @@ describe('PKIAuthenticationProvider', () => {
await expect(operation(request)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'pki' },
{ ...user, authentication_provider: { type: 'pki', name: 'pki' } },
{
authHeaders: { authorization: 'Bearer access-token' },
state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' },
@ -169,7 +169,7 @@ describe('PKIAuthenticationProvider', () => {
await expect(operation(request)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'pki' },
{ ...user, authentication_provider: { type: 'pki', name: 'pki' } },
{
authHeaders: { authorization: 'Bearer access-token' },
state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' },
@ -356,7 +356,7 @@ describe('PKIAuthenticationProvider', () => {
await expect(provider.authenticate(request, state)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'pki' },
{ ...user, authentication_provider: { type: 'pki', name: 'pki' } },
{
authHeaders: { authorization: 'Bearer access-token' },
state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' },
@ -405,7 +405,7 @@ describe('PKIAuthenticationProvider', () => {
await expect(provider.authenticate(request, state)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'pki' },
{ ...user, authentication_provider: { type: 'pki', name: 'pki' } },
{
authHeaders: { authorization: 'Bearer access-token' },
state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' },
@ -486,7 +486,7 @@ describe('PKIAuthenticationProvider', () => {
await expect(provider.authenticate(request, state)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'pki' },
{ ...user, authentication_provider: { type: 'pki', name: 'pki' } },
{ authHeaders: { authorization: `Bearer ${state.accessToken}` } }
)
);

View file

@ -28,7 +28,7 @@ describe('SAMLAuthenticationProvider', () => {
mockOptions = mockAuthenticationProviderOptions({ name: 'saml' });
mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
mockUser = mockAuthenticatedUser({ authentication_provider: 'saml' });
mockUser = mockAuthenticatedUser({ authentication_provider: { type: 'saml', name: 'saml' } });
mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method) => {
if (method === 'shield.authenticate') {
return mockUser;
@ -542,7 +542,9 @@ describe('SAMLAuthenticationProvider', () => {
for (const [description, response] of [
[
'current session is valid',
Promise.resolve(mockAuthenticatedUser({ authentication_provider: 'saml' })),
Promise.resolve(
mockAuthenticatedUser({ authentication_provider: { type: 'saml', name: 'saml' } })
),
],
[
'current session is is expired',

View file

@ -60,7 +60,7 @@ describe('TokenAuthenticationProvider', () => {
await expect(provider.login(request, credentials)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'token' },
{ ...user, authentication_provider: { type: 'token', name: 'token' } },
{ authHeaders: { authorization }, state: tokenPair }
)
);
@ -196,7 +196,7 @@ describe('TokenAuthenticationProvider', () => {
await expect(provider.authenticate(request, tokenPair)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'token' },
{ ...user, authentication_provider: { type: 'token', name: 'token' } },
{ authHeaders: { authorization } }
)
);
@ -236,7 +236,7 @@ describe('TokenAuthenticationProvider', () => {
await expect(provider.authenticate(request, tokenPair)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'token' },
{ ...user, authentication_provider: { type: 'token', name: 'token' } },
{
authHeaders: { authorization: 'Bearer newfoo' },
state: { accessToken: 'newfoo', refreshToken: 'newbar' },

View file

@ -36,6 +36,10 @@ describe('config schema', () => {
"hint": undefined,
"icon": undefined,
"order": 0,
"session": Object {
"idleTimeout": undefined,
"lifespan": undefined,
},
"showInSelector": true,
},
},
@ -55,8 +59,6 @@ describe('config schema', () => {
"secureCookies": false,
"session": Object {
"cleanupInterval": "PT1H",
"idleTimeout": null,
"lifespan": null,
},
}
`);
@ -83,6 +85,10 @@ describe('config schema', () => {
"hint": undefined,
"icon": undefined,
"order": 0,
"session": Object {
"idleTimeout": undefined,
"lifespan": undefined,
},
"showInSelector": true,
},
},
@ -102,8 +108,6 @@ describe('config schema', () => {
"secureCookies": false,
"session": Object {
"cleanupInterval": "PT1H",
"idleTimeout": null,
"lifespan": null,
},
}
`);
@ -130,6 +134,10 @@ describe('config schema', () => {
"hint": undefined,
"icon": undefined,
"order": 0,
"session": Object {
"idleTimeout": undefined,
"lifespan": undefined,
},
"showInSelector": true,
},
},
@ -148,8 +156,6 @@ describe('config schema', () => {
"secureCookies": false,
"session": Object {
"cleanupInterval": "PT1H",
"idleTimeout": null,
"lifespan": null,
},
}
`);
@ -521,6 +527,35 @@ describe('config schema', () => {
"enabled": true,
"icon": "logoElasticsearch",
"order": 0,
"session": Object {},
"showInSelector": true,
},
},
}
`);
});
it('can be successfully validated with session config overrides', () => {
expect(
ConfigSchema.validate({
authc: {
providers: {
basic: { basic1: { order: 0, session: { idleTimeout: 123, lifespan: 546 } } },
},
},
}).authc.providers
).toMatchInlineSnapshot(`
Object {
"basic": Object {
"basic1": Object {
"description": "Log in with Elasticsearch",
"enabled": true,
"icon": "logoElasticsearch",
"order": 0,
"session": Object {
"idleTimeout": "PT0.123S",
"lifespan": "PT0.546S",
},
"showInSelector": true,
},
},
@ -573,6 +608,35 @@ describe('config schema', () => {
"enabled": true,
"icon": "logoElasticsearch",
"order": 0,
"session": Object {},
"showInSelector": true,
},
},
}
`);
});
it('can be successfully validated with session config overrides', () => {
expect(
ConfigSchema.validate({
authc: {
providers: {
token: { token1: { order: 0, session: { idleTimeout: 123, lifespan: 546 } } },
},
},
}).authc.providers
).toMatchInlineSnapshot(`
Object {
"token": Object {
"token1": Object {
"description": "Log in with Elasticsearch",
"enabled": true,
"icon": "logoElasticsearch",
"order": 0,
"session": Object {
"idleTimeout": "PT0.123S",
"lifespan": "PT0.546S",
},
"showInSelector": true,
},
},
@ -611,6 +675,33 @@ describe('config schema', () => {
"pki1": Object {
"enabled": true,
"order": 0,
"session": Object {},
"showInSelector": true,
},
},
}
`);
});
it('can be successfully validated with session config overrides', () => {
expect(
ConfigSchema.validate({
authc: {
providers: {
pki: { pki1: { order: 0, session: { idleTimeout: 123, lifespan: 546 } } },
},
},
}).authc.providers
).toMatchInlineSnapshot(`
Object {
"pki": Object {
"pki1": Object {
"enabled": true,
"order": 0,
"session": Object {
"idleTimeout": "PT0.123S",
"lifespan": "PT0.546S",
},
"showInSelector": true,
},
},
@ -651,6 +742,33 @@ describe('config schema', () => {
"kerberos1": Object {
"enabled": true,
"order": 0,
"session": Object {},
"showInSelector": true,
},
},
}
`);
});
it('can be successfully validated with session config overrides', () => {
expect(
ConfigSchema.validate({
authc: {
providers: {
kerberos: { kerberos1: { order: 0, session: { idleTimeout: 123, lifespan: 546 } } },
},
},
}).authc.providers
).toMatchInlineSnapshot(`
Object {
"kerberos": Object {
"kerberos1": Object {
"enabled": true,
"order": 0,
"session": Object {
"idleTimeout": "PT0.123S",
"lifespan": "PT0.546S",
},
"showInSelector": true,
},
},
@ -696,12 +814,53 @@ describe('config schema', () => {
"enabled": true,
"order": 0,
"realm": "oidc1",
"session": Object {},
"showInSelector": true,
},
"oidc2": Object {
"enabled": true,
"order": 1,
"realm": "oidc2",
"session": Object {},
"showInSelector": true,
},
},
}
`);
});
it('can be successfully validated with session config overrides', () => {
expect(
ConfigSchema.validate({
authc: {
providers: {
oidc: {
oidc1: { order: 0, realm: 'oidc1', session: { idleTimeout: 123 } },
oidc2: { order: 1, realm: 'oidc2', session: { idleTimeout: 321, lifespan: 546 } },
},
},
},
}).authc.providers
).toMatchInlineSnapshot(`
Object {
"oidc": Object {
"oidc1": Object {
"enabled": true,
"order": 0,
"realm": "oidc1",
"session": Object {
"idleTimeout": "PT0.123S",
},
"showInSelector": true,
},
"oidc2": Object {
"enabled": true,
"order": 1,
"realm": "oidc2",
"session": Object {
"idleTimeout": "PT0.321S",
"lifespan": "PT0.546S",
},
"showInSelector": true,
},
},
@ -751,6 +910,7 @@ describe('config schema', () => {
"enabled": true,
"order": 0,
"realm": "saml1",
"session": Object {},
"showInSelector": true,
"useRelayStateDeepLink": false,
},
@ -761,6 +921,7 @@ describe('config schema', () => {
},
"order": 1,
"realm": "saml2",
"session": Object {},
"showInSelector": true,
"useRelayStateDeepLink": false,
},
@ -768,6 +929,65 @@ describe('config schema', () => {
"enabled": true,
"order": 2,
"realm": "saml3",
"session": Object {},
"showInSelector": true,
"useRelayStateDeepLink": true,
},
},
}
`);
});
it('can be successfully validated with session config overrides', () => {
expect(
ConfigSchema.validate({
authc: {
providers: {
saml: {
saml1: { order: 0, realm: 'saml1', session: { idleTimeout: 123 } },
saml2: {
order: 1,
realm: 'saml2',
maxRedirectURLSize: '1kb',
session: { idleTimeout: 321, lifespan: 546 },
},
saml3: { order: 2, realm: 'saml3', useRelayStateDeepLink: true },
},
},
},
}).authc.providers
).toMatchInlineSnapshot(`
Object {
"saml": Object {
"saml1": Object {
"enabled": true,
"order": 0,
"realm": "saml1",
"session": Object {
"idleTimeout": "PT0.123S",
},
"showInSelector": true,
"useRelayStateDeepLink": false,
},
"saml2": Object {
"enabled": true,
"maxRedirectURLSize": ByteSizeValue {
"valueInBytes": 1024,
},
"order": 1,
"realm": "saml2",
"session": Object {
"idleTimeout": "PT0.321S",
"lifespan": "PT0.546S",
},
"showInSelector": true,
"useRelayStateDeepLink": false,
},
"saml3": Object {
"enabled": true,
"order": 2,
"realm": "saml3",
"session": Object {},
"showInSelector": true,
"useRelayStateDeepLink": true,
},
@ -835,6 +1055,7 @@ describe('config schema', () => {
"enabled": true,
"icon": "logoElasticsearch",
"order": 0,
"session": Object {},
"showInSelector": true,
},
"basic2": Object {
@ -842,6 +1063,7 @@ describe('config schema', () => {
"enabled": false,
"icon": "logoElasticsearch",
"order": 1,
"session": Object {},
"showInSelector": true,
},
},
@ -850,6 +1072,7 @@ describe('config schema', () => {
"enabled": false,
"order": 3,
"realm": "saml3",
"session": Object {},
"showInSelector": true,
"useRelayStateDeepLink": false,
},
@ -857,6 +1080,7 @@ describe('config schema', () => {
"enabled": true,
"order": 1,
"realm": "saml1",
"session": Object {},
"showInSelector": true,
"useRelayStateDeepLink": false,
},
@ -864,6 +1088,7 @@ describe('config schema', () => {
"enabled": true,
"order": 2,
"realm": "saml2",
"session": Object {},
"showInSelector": true,
"useRelayStateDeepLink": false,
},
@ -1223,4 +1448,314 @@ describe('createConfig()', () => {
'[audit]: xpack.security.audit.ignore_filters can only be used with the ECS audit logger. To enable the ECS audit logger, specify where you want to write the audit events using xpack.security.audit.appender.'
);
});
describe('#getExpirationTimeouts', () => {
function createMockConfig(config: Record<string, any> = {}) {
return createConfig(ConfigSchema.validate(config), loggingSystemMock.createLogger(), {
isTLSEnabled: false,
});
}
it('returns default values if neither global nor provider specific settings are set', async () => {
expect(createMockConfig().session.getExpirationTimeouts({ type: 'basic', name: 'basic1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": null,
"lifespan": null,
}
`);
});
it('correctly handles explicitly disabled global settings', async () => {
expect(
createMockConfig({
session: { idleTimeout: null, lifespan: null },
}).session.getExpirationTimeouts({ type: 'basic', name: 'basic1' })
).toMatchInlineSnapshot(`
Object {
"idleTimeout": null,
"lifespan": null,
}
`);
expect(
createMockConfig({
session: { idleTimeout: 0, lifespan: 0 },
}).session.getExpirationTimeouts({ type: 'basic', name: 'basic1' })
).toMatchInlineSnapshot(`
Object {
"idleTimeout": null,
"lifespan": null,
}
`);
});
it('falls back to the global settings if provider does not override them', async () => {
expect(
createMockConfig({ session: { idleTimeout: 123 } }).session.getExpirationTimeouts({
type: 'basic',
name: 'basic1',
})
).toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT0.123S",
"lifespan": null,
}
`);
expect(
createMockConfig({ session: { lifespan: 456 } }).session.getExpirationTimeouts({
type: 'basic',
name: 'basic1',
})
).toMatchInlineSnapshot(`
Object {
"idleTimeout": null,
"lifespan": "PT0.456S",
}
`);
expect(
createMockConfig({
session: { idleTimeout: 123, lifespan: 456 },
}).session.getExpirationTimeouts({ type: 'basic', name: 'basic1' })
).toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT0.123S",
"lifespan": "PT0.456S",
}
`);
});
it('falls back to the global settings if provider is not known', async () => {
expect(
createMockConfig({ session: { idleTimeout: 123 } }).session.getExpirationTimeouts({
type: 'some type',
name: 'some name',
})
).toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT0.123S",
"lifespan": null,
}
`);
expect(
createMockConfig({ session: { lifespan: 456 } }).session.getExpirationTimeouts({
type: 'some type',
name: 'some name',
})
).toMatchInlineSnapshot(`
Object {
"idleTimeout": null,
"lifespan": "PT0.456S",
}
`);
expect(
createMockConfig({
session: { idleTimeout: 123, lifespan: 456 },
}).session.getExpirationTimeouts({ type: 'some type', name: 'some name' })
).toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT0.123S",
"lifespan": "PT0.456S",
}
`);
});
it('uses provider overrides if specified (only idle timeout)', async () => {
const configWithoutGlobal = createMockConfig({
authc: {
providers: {
basic: { basic1: { order: 0, session: { idleTimeout: 321 } } },
saml: { saml1: { order: 1, realm: 'saml-realm', session: { idleTimeout: 332211 } } },
},
},
session: { idleTimeout: null },
});
expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT0.321S",
"lifespan": null,
}
`);
expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'saml', name: 'saml1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT5M32.211S",
"lifespan": null,
}
`);
const configWithGlobal = createMockConfig({
authc: {
providers: {
basic: { basic1: { order: 0, session: { idleTimeout: 321 } } },
saml: { saml1: { order: 1, realm: 'saml-realm', session: { idleTimeout: 332211 } } },
},
},
session: { idleTimeout: 123 },
});
expect(configWithGlobal.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT0.321S",
"lifespan": null,
}
`);
expect(configWithGlobal.session.getExpirationTimeouts({ type: 'saml', name: 'saml1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT5M32.211S",
"lifespan": null,
}
`);
});
it('uses provider overrides if specified (only lifespan)', async () => {
const configWithoutGlobal = createMockConfig({
authc: {
providers: {
basic: { basic1: { order: 0, session: { lifespan: 654 } } },
saml: { saml1: { order: 1, realm: 'saml-realm', session: { lifespan: 665544 } } },
},
},
session: { lifespan: null },
});
expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": null,
"lifespan": "PT0.654S",
}
`);
expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'saml', name: 'saml1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": null,
"lifespan": "PT11M5.544S",
}
`);
const configWithGlobal = createMockConfig({
authc: {
providers: {
basic: { basic1: { order: 0, session: { lifespan: 654 } } },
saml: { saml1: { order: 1, realm: 'saml-realm', session: { idleTimeout: 665544 } } },
},
},
session: { lifespan: 456 },
});
expect(configWithGlobal.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": null,
"lifespan": "PT0.654S",
}
`);
expect(configWithGlobal.session.getExpirationTimeouts({ type: 'saml', name: 'saml1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT11M5.544S",
"lifespan": "PT0.456S",
}
`);
});
it('uses provider overrides if specified (both idle timeout and lifespan)', async () => {
const configWithoutGlobal = createMockConfig({
authc: {
providers: {
basic: { basic1: { order: 0, session: { idleTimeout: 321, lifespan: 654 } } },
saml: {
saml1: {
order: 1,
realm: 'saml-realm',
session: { idleTimeout: 332211, lifespan: 665544 },
},
},
},
},
session: { idleTimeout: null, lifespan: null },
});
expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT0.321S",
"lifespan": "PT0.654S",
}
`);
expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'saml', name: 'saml1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT5M32.211S",
"lifespan": "PT11M5.544S",
}
`);
const configWithGlobal = createMockConfig({
authc: {
providers: {
basic: { basic1: { order: 0, session: { idleTimeout: 321, lifespan: 654 } } },
saml: {
saml1: {
order: 1,
realm: 'saml-realm',
session: { idleTimeout: 332211, lifespan: 665544 },
},
},
},
},
session: { idleTimeout: 123, lifespan: 456 },
});
expect(configWithGlobal.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT0.321S",
"lifespan": "PT0.654S",
}
`);
expect(configWithGlobal.session.getExpirationTimeouts({ type: 'saml', name: 'saml1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": "PT5M32.211S",
"lifespan": "PT11M5.544S",
}
`);
});
it('uses provider overrides if disabled (both idle timeout and lifespan)', async () => {
const config = createMockConfig({
authc: {
providers: {
basic: { basic1: { order: 0, session: { idleTimeout: null, lifespan: null } } },
saml: {
saml1: {
order: 1,
realm: 'saml-realm',
session: { idleTimeout: 0, lifespan: 0 },
},
},
},
},
session: { idleTimeout: 123, lifespan: 456 },
});
expect(config.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": null,
"lifespan": null,
}
`);
expect(config.session.getExpirationTimeouts({ type: 'saml', name: 'saml1' }))
.toMatchInlineSnapshot(`
Object {
"idleTimeout": null,
"lifespan": null,
}
`);
});
});
});

View file

@ -5,11 +5,24 @@
*/
import crypto from 'crypto';
import type { Duration } from 'moment';
import { schema, Type, TypeOf } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { Logger, config as coreConfig } from '../../../../src/core/server';
import type { AuthenticationProvider } from '../common/types';
export type ConfigType = ReturnType<typeof createConfig>;
type RawConfigType = TypeOf<typeof ConfigSchema>;
interface ProvidersCommonConfigType {
enabled: Type<boolean>;
showInSelector: Type<boolean>;
order: Type<number>;
description?: Type<string>;
hint?: Type<string>;
icon?: Type<string>;
session?: Type<{ idleTimeout?: Duration | null; lifespan?: Duration | null }>;
}
const providerOptionsSchema = (providerType: string, optionsSchema: Type<any>) =>
schema.conditional(
@ -21,10 +34,6 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type<any>) =
schema.never()
);
type ProvidersCommonConfigType = Record<
'enabled' | 'showInSelector' | 'order' | 'description' | 'hint' | 'icon',
Type<any>
>;
function getCommonProviderSchemaProperties(overrides: Partial<ProvidersCommonConfigType> = {}) {
return {
enabled: schema.boolean({ defaultValue: true }),
@ -34,6 +43,10 @@ function getCommonProviderSchemaProperties(overrides: Partial<ProvidersCommonCon
hint: schema.maybe(schema.string()),
icon: schema.maybe(schema.string()),
accessAgreement: schema.maybe(schema.object({ message: schema.string() })),
session: schema.object({
idleTimeout: schema.maybe(schema.oneOf([schema.duration(), schema.literal(null)])),
lifespan: schema.maybe(schema.oneOf([schema.duration(), schema.literal(null)])),
}),
...overrides,
};
}
@ -147,8 +160,8 @@ export const ConfigSchema = schema.object({
schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) })
),
session: schema.object({
idleTimeout: schema.nullable(schema.duration()),
lifespan: schema.nullable(schema.duration()),
idleTimeout: schema.maybe(schema.oneOf([schema.duration(), schema.literal(null)])),
lifespan: schema.maybe(schema.oneOf([schema.duration(), schema.literal(null)])),
cleanupInterval: schema.duration({
defaultValue: '1h',
validate(value) {
@ -180,6 +193,7 @@ export const ConfigSchema = schema.object({
hint: undefined,
icon: undefined,
accessAgreement: undefined,
session: { idleTimeout: undefined, lifespan: undefined },
},
},
token: undefined,
@ -230,7 +244,7 @@ export const ConfigSchema = schema.object({
});
export function createConfig(
config: TypeOf<typeof ConfigSchema>,
config: RawConfigType,
logger: Logger,
{ isTLSEnabled }: { isTLSEnabled: boolean }
) {
@ -319,7 +333,33 @@ export function createConfig(
sortedProviders: Object.freeze(sortedProviders),
http: config.authc.http,
},
session: getSessionConfig(config.session, providers),
encryptionKey,
secureCookies,
};
}
function getSessionConfig(session: RawConfigType['session'], providers: ProvidersConfigType) {
return {
cleanupInterval: session.cleanupInterval,
getExpirationTimeouts({ type, name }: AuthenticationProvider) {
// Both idle timeout and lifespan from the provider specific session config can have three
// possible types of values: `Duration`, `null` and `undefined`. The `undefined` type means that
// provider doesn't override session config and we should fall back to the global one instead.
const providerSessionConfig = providers[type as keyof ProvidersConfigType]?.[name]?.session;
const [idleTimeout, lifespan] = [
[session.idleTimeout, providerSessionConfig?.idleTimeout],
[session.lifespan, providerSessionConfig?.lifespan],
].map(([globalTimeout, providerTimeout]) => {
const timeout = providerTimeout === undefined ? globalTimeout ?? null : providerTimeout;
return timeout && timeout.asMilliseconds() > 0 ? timeout : null;
});
return {
idleTimeout,
lifespan,
};
},
};
}

View file

@ -195,7 +195,7 @@ describe('Change password', () => {
it('successfully changes own password if provided old password is correct for non-basic provider.', async () => {
const mockUser = mockAuthenticatedUser({
username: 'user',
authentication_provider: 'token1',
authentication_provider: { type: 'token', name: 'token1' },
});
authc.getCurrentUser.mockReturnValue(mockUser);
authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockUser));

View file

@ -4,16 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
import nodeCrypto, { Crypto } from '@elastic/node-crypto';
import type { PublicMethodsOf } from '@kbn/utility-types';
import { promisify } from 'util';
import { randomBytes, createHash } from 'crypto';
import { Duration } from 'moment';
import { KibanaRequest, Logger } from '../../../../../src/core/server';
import { AuthenticationProvider } from '../../common/types';
import { ConfigType } from '../config';
import { SessionIndex, SessionIndexValue } from './session_index';
import { SessionCookie } from './session_cookie';
import nodeCrypto, { Crypto } from '@elastic/node-crypto';
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { KibanaRequest, Logger } from '../../../../../src/core/server';
import type { AuthenticationProvider } from '../../common/types';
import type { ConfigType } from '../config';
import type { SessionIndex, SessionIndexValue } from './session_index';
import type { SessionCookie } from './session_cookie';
/**
* The shape of the value that represents user's session information.
@ -86,21 +85,6 @@ const SID_BYTE_LENGTH = 32;
const AAD_BYTE_LENGTH = 32;
export class Session {
/**
* Session idle timeout in ms. If `null`, a session will stay active until its max lifespan is reached.
*/
private readonly idleTimeout: Duration | null;
/**
* Timeout after which idle timeout property is updated in the index.
*/
private readonly idleIndexUpdateTimeout: number | null;
/**
* Session max lifespan in ms. If `null` session may live indefinitely.
*/
private readonly lifespan: Duration | null;
/**
* Used to encrypt and decrypt portion of the session value using configured encryption key.
*/
@ -113,14 +97,6 @@ export class Session {
constructor(private readonly options: Readonly<SessionOptions>) {
this.crypto = nodeCrypto({ encryptionKey: this.options.config.encryptionKey });
this.idleTimeout = this.options.config.session.idleTimeout;
this.lifespan = this.options.config.session.lifespan;
// The timeout after which we update index is two times longer than configured idle timeout
// since index updates are costly and we want to minimize them.
this.idleIndexUpdateTimeout = this.options.config.session.idleTimeout
? this.options.config.session.idleTimeout.asMilliseconds() * 2
: null;
}
/**
@ -194,7 +170,7 @@ export class Session {
const sessionLogger = this.getLoggerForSID(sid);
sessionLogger.debug('Creating a new session.');
const sessionExpirationInfo = this.calculateExpiry();
const sessionExpirationInfo = this.calculateExpiry(sessionValue.provider);
const { username, state, ...publicSessionValue } = sessionValue;
// First try to store session in the index and only then in the cookie to make sure cookie is
@ -227,7 +203,10 @@ export class Session {
return null;
}
const sessionExpirationInfo = this.calculateExpiry(sessionCookieValue.lifespanExpiration);
const sessionExpirationInfo = this.calculateExpiry(
sessionValue.provider,
sessionCookieValue.lifespanExpiration
);
const { username, state, metadata, ...publicSessionInfo } = sessionValue;
// First try to store session in the index and only then in the cookie to make sure cookie is
@ -276,7 +255,10 @@ export class Session {
// We calculate actual expiration values based on the information extracted from the portion of
// the session value that is stored in the cookie since it always contains the most recent value.
const sessionExpirationInfo = this.calculateExpiry(sessionCookieValue.lifespanExpiration);
const sessionExpirationInfo = this.calculateExpiry(
sessionValue.provider,
sessionCookieValue.lifespanExpiration
);
if (
sessionExpirationInfo.idleTimeoutExpiration === sessionValue.idleTimeoutExpiration &&
sessionExpirationInfo.lifespanExpiration === sessionValue.lifespanExpiration
@ -311,17 +293,24 @@ export class Session {
'Session lifespan configuration has changed, session index will be updated.'
);
updateSessionIndex = true;
} else if (
this.idleIndexUpdateTimeout !== null &&
this.idleIndexUpdateTimeout <
sessionExpirationInfo.idleTimeoutExpiration! -
sessionValue.metadata.index.idleTimeoutExpiration!
) {
// 3. If idle timeout was updated a while ago.
sessionLogger.debug(
'Session idle timeout stored in the index is too old and will be updated.'
} else {
// The timeout after which we update index is two times longer than configured idle timeout
// since index updates are costly and we want to minimize them.
const { idleTimeout } = this.options.config.session.getExpirationTimeouts(
sessionValue.provider
);
updateSessionIndex = true;
if (
idleTimeout !== null &&
idleTimeout.asMilliseconds() * 2 <
sessionExpirationInfo.idleTimeoutExpiration! -
sessionValue.metadata.index.idleTimeoutExpiration!
) {
// 3. If idle timeout was updated a while ago.
sessionLogger.debug(
'Session idle timeout stored in the index is too old and will be updated.'
);
updateSessionIndex = true;
}
}
// First try to store session in the index and only then in the cookie to make sure cookie is
@ -375,18 +364,21 @@ export class Session {
}
private calculateExpiry(
provider: AuthenticationProvider,
currentLifespanExpiration?: number | null
): { idleTimeoutExpiration: number | null; lifespanExpiration: number | null } {
const now = Date.now();
const { idleTimeout, lifespan } = this.options.config.session.getExpirationTimeouts(provider);
// if we are renewing an existing session, use its `lifespanExpiration` -- otherwise, set this value
// based on the configured server `lifespan`.
// note, if the server had a `lifespan` set and then removes it, remove `lifespanExpiration` on renewed sessions
// also, if the server did not have a `lifespan` set and then adds it, add `lifespanExpiration` on renewed sessions
const lifespanExpiration =
currentLifespanExpiration && this.lifespan
currentLifespanExpiration && lifespan
? currentLifespanExpiration
: this.lifespan && now + this.lifespan.asMilliseconds();
const idleTimeoutExpiration = this.idleTimeout && now + this.idleTimeout.asMilliseconds();
: lifespan && now + lifespan.asMilliseconds();
const idleTimeoutExpiration = idleTimeout && now + idleTimeout.asMilliseconds();
return { idleTimeoutExpiration, lifespanExpiration };
}

View file

@ -194,8 +194,39 @@ describe('Session index', () => {
query: {
bool: {
should: [
// All expired sessions based on the lifespan, no matter which provider they belong to.
{ range: { lifespanExpiration: { lte: now } } },
{ range: { idleTimeoutExpiration: { lte: now } } },
// All sessions that belong to the providers that aren't configured.
{
bool: {
must_not: {
bool: {
should: [
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic' } },
],
},
},
],
minimum_should_match: 1,
},
},
},
},
// The sessions that belong to a particular provider that are expired based on the idle timeout.
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic' } },
],
should: [{ range: { idleTimeoutExpiration: { lte: now } } }],
minimum_should_match: 1,
},
},
],
},
},
@ -226,9 +257,49 @@ describe('Session index', () => {
query: {
bool: {
should: [
// All expired sessions based on the lifespan, no matter which provider they belong to.
{ range: { lifespanExpiration: { lte: now } } },
{ bool: { must_not: { exists: { field: 'lifespanExpiration' } } } },
{ range: { idleTimeoutExpiration: { lte: now } } },
// All sessions that belong to the providers that aren't configured.
{
bool: {
must_not: {
bool: {
should: [
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic' } },
],
},
},
],
minimum_should_match: 1,
},
},
},
},
// The sessions that belong to a particular provider but don't have a configured lifespan.
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic' } },
],
must_not: { exists: { field: 'lifespanExpiration' } },
},
},
// The sessions that belong to a particular provider that are expired based on the idle timeout.
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic' } },
],
should: [{ range: { idleTimeoutExpiration: { lte: now } } }],
minimum_should_match: 1,
},
},
],
},
},
@ -260,9 +331,43 @@ describe('Session index', () => {
query: {
bool: {
should: [
// All expired sessions based on the lifespan, no matter which provider they belong to.
{ range: { lifespanExpiration: { lte: now } } },
{ range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } },
{ bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } },
// All sessions that belong to the providers that aren't configured.
{
bool: {
must_not: {
bool: {
should: [
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic' } },
],
},
},
],
minimum_should_match: 1,
},
},
},
},
// The sessions that belong to a particular provider that are either expired based on the idle timeout
// or don't have it configured at all.
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic' } },
],
should: [
{ range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } },
{ bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } },
],
minimum_should_match: 1,
},
},
],
},
},
@ -294,10 +399,179 @@ describe('Session index', () => {
query: {
bool: {
should: [
// All expired sessions based on the lifespan, no matter which provider they belong to.
{ range: { lifespanExpiration: { lte: now } } },
{ bool: { must_not: { exists: { field: 'lifespanExpiration' } } } },
{ range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } },
{ bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } },
// All sessions that belong to the providers that aren't configured.
{
bool: {
must_not: {
bool: {
should: [
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic' } },
],
},
},
],
minimum_should_match: 1,
},
},
},
},
// The sessions that belong to a particular provider but don't have a configured lifespan.
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic' } },
],
must_not: { exists: { field: 'lifespanExpiration' } },
},
},
// The sessions that belong to a particular provider that are either expired based on the idle timeout
// or don't have it configured at all.
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic' } },
],
should: [
{ range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } },
{ bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } },
],
minimum_should_match: 1,
},
},
],
},
},
},
});
});
it('when both `lifespan` and `idleTimeout` are configured and multiple providers are enabled', async () => {
const globalIdleTimeout = 123;
const samlIdleTimeout = 33221;
sessionIndex = new SessionIndex({
logger: loggingSystemMock.createLogger(),
kibanaIndexName: '.kibana_some_tenant',
config: createConfig(
ConfigSchema.validate({
session: { idleTimeout: globalIdleTimeout, lifespan: 456 },
authc: {
providers: {
basic: { basic1: { order: 0 } },
saml: {
saml1: {
order: 1,
realm: 'saml-realm',
session: { idleTimeout: samlIdleTimeout },
},
},
},
},
}),
loggingSystemMock.createLogger(),
{ isTLSEnabled: false }
),
clusterClient: mockClusterClient,
});
await sessionIndex.cleanUp();
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', {
index: indexName,
refresh: 'wait_for',
ignore: [409, 404],
body: {
query: {
bool: {
should: [
// All expired sessions based on the lifespan, no matter which provider they belong to.
{ range: { lifespanExpiration: { lte: now } } },
// All sessions that belong to the providers that aren't configured.
{
bool: {
must_not: {
bool: {
should: [
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic1' } },
],
},
},
{
bool: {
must: [
{ term: { 'provider.type': 'saml' } },
{ term: { 'provider.name': 'saml1' } },
],
},
},
],
minimum_should_match: 1,
},
},
},
},
// The sessions that belong to a Basic provider but don't have a configured lifespan.
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic1' } },
],
must_not: { exists: { field: 'lifespanExpiration' } },
},
},
// The sessions that belong to a Basic provider that are either expired based on the idle timeout
// or don't have it configured at all.
{
bool: {
must: [
{ term: { 'provider.type': 'basic' } },
{ term: { 'provider.name': 'basic1' } },
],
should: [
{ range: { idleTimeoutExpiration: { lte: now - 3 * globalIdleTimeout } } },
{ bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } },
],
minimum_should_match: 1,
},
},
// The sessions that belong to a SAML provider but don't have a configured lifespan.
{
bool: {
must: [
{ term: { 'provider.type': 'saml' } },
{ term: { 'provider.name': 'saml1' } },
],
must_not: { exists: { field: 'lifespanExpiration' } },
},
},
// The sessions that belong to a SAML provider that are either expired based on the idle timeout
// or don't have it configured at all.
{
bool: {
must: [
{ term: { 'provider.type': 'saml' } },
{ term: { 'provider.name': 'saml1' } },
],
should: [
{ range: { idleTimeoutExpiration: { lte: now - 3 * samlIdleTimeout } } },
{ bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } },
],
minimum_should_match: 1,
},
},
],
},
},

View file

@ -4,14 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ILegacyClusterClient, Logger } from '../../../../../src/core/server';
import { AuthenticationProvider } from '../../common/types';
import { ConfigType } from '../config';
import type { ILegacyClusterClient, Logger } from '../../../../../src/core/server';
import type { AuthenticationProvider } from '../../common/types';
import type { ConfigType } from '../config';
export interface SessionIndexOptions {
readonly clusterClient: ILegacyClusterClient;
readonly kibanaIndexName: string;
readonly config: Pick<ConfigType, 'session'>;
readonly config: Pick<ConfigType, 'session' | 'authc'>;
readonly logger: Logger;
}
@ -120,12 +120,6 @@ export class SessionIndex {
*/
private readonly indexName = `${this.options.kibanaIndexName}_security_session_${SESSION_INDEX_TEMPLATE_VERSION}`;
/**
* Timeout after which session with the expired idle timeout _may_ be removed from the index
* during regular cleanup routine.
*/
private readonly idleIndexCleanupTimeout: number | null;
/**
* Promise that tracks session index initialization process. We'll need to get rid of this as soon
* as Core provides support for plugin statuses (https://github.com/elastic/kibana/issues/41983).
@ -134,14 +128,7 @@ export class SessionIndex {
*/
private indexInitialization?: Promise<void>;
constructor(private readonly options: Readonly<SessionIndexOptions>) {
// This timeout is intentionally larger than the `idleIndexUpdateTimeout` (idleTimeout * 2)
// configured in `Session` to be sure that the session value is definitely expired and may be
// safely cleaned up.
this.idleIndexCleanupTimeout = this.options.config.session.idleTimeout
? this.options.config.session.idleTimeout.asMilliseconds() * 3
: null;
}
constructor(private readonly options: Readonly<SessionIndexOptions>) {}
/**
* Retrieves session value with the specified ID from the index. If session value isn't found
@ -353,26 +340,62 @@ export class SessionIndex {
this.options.logger.debug(`Running cleanup routine.`);
const now = Date.now();
const providersSessionConfig = this.options.config.authc.sortedProviders.map((provider) => {
return {
boolQuery: {
bool: {
must: [
{ term: { 'provider.type': provider.type } },
{ term: { 'provider.name': provider.name } },
],
},
},
...this.options.config.session.getExpirationTimeouts(provider),
};
});
// Always try to delete sessions with expired lifespan (even if it's not configured right now).
const deleteQueries: object[] = [{ range: { lifespanExpiration: { lte: now } } }];
// If lifespan is configured we should remove any sessions that were created without one.
if (this.options.config.session.lifespan) {
deleteQueries.push({ bool: { must_not: { exists: { field: 'lifespanExpiration' } } } });
}
// If session belongs to a not configured provider we should also remove it.
deleteQueries.push({
bool: {
must_not: {
bool: {
should: providersSessionConfig.map(({ boolQuery }) => boolQuery),
minimum_should_match: 1,
},
},
},
});
// If idle timeout is configured we should delete all sessions without specified idle timeout
// or if that session hasn't been updated for a while meaning that session is expired.
if (this.idleIndexCleanupTimeout) {
deleteQueries.push(
{ range: { idleTimeoutExpiration: { lte: now - this.idleIndexCleanupTimeout } } },
{ bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }
);
} else {
// Otherwise just delete all expired sessions that were previously created with the idle
// timeout.
deleteQueries.push({ range: { idleTimeoutExpiration: { lte: now } } });
for (const { boolQuery, lifespan, idleTimeout } of providersSessionConfig) {
// If lifespan is configured we should remove any sessions that were created without one.
if (lifespan) {
deleteQueries.push({
bool: { ...boolQuery.bool, must_not: { exists: { field: 'lifespanExpiration' } } },
});
}
// This timeout is intentionally larger than the timeout used in `Session` to update idle
// timeout in the session index (idleTimeout * 2) to be sure that the session value is
// definitely expired and may be safely cleaned up.
const idleIndexCleanupTimeout = idleTimeout ? idleTimeout.asMilliseconds() * 3 : null;
deleteQueries.push({
bool: {
...boolQuery.bool,
// If idle timeout is configured we should delete all sessions without specified idle timeout
// or if that session hasn't been updated for a while meaning that session is expired. Otherwise
// just delete all expired sessions that were previously created with the idle timeout.
should: idleIndexCleanupTimeout
? [
{ range: { idleTimeoutExpiration: { lte: now - idleIndexCleanupTimeout } } },
{ bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } },
]
: [{ range: { idleTimeoutExpiration: { lte: now } } }],
minimum_should_match: 1,
},
});
}
try {

View file

@ -147,9 +147,9 @@ export default function ({ getService }) {
'authentication_type',
]);
expect(apiResponse.body.username).to.be(validUsername);
expect(apiResponse.body.authentication_provider).to.eql('__http__');
expect(apiResponse.body.authentication_provider).to.eql({ type: 'http', name: '__http__' });
expect(apiResponse.body.authentication_type).to.be('realm');
// Do not assert on the `authentication_realm`, as the value differes for on-prem vs cloud
// Do not assert on the `authentication_realm`, as the value differs for on-prem vs cloud
});
describe('with session cookie', () => {
@ -193,9 +193,9 @@ export default function ({ getService }) {
'authentication_type',
]);
expect(apiResponse.body.username).to.be(validUsername);
expect(apiResponse.body.authentication_provider).to.eql('basic');
expect(apiResponse.body.authentication_provider).to.eql({ type: 'basic', name: 'basic' });
expect(apiResponse.body.authentication_type).to.be('realm');
// Do not assert on the `authentication_realm`, as the value differes for on-prem vs cloud
// Do not assert on the `authentication_realm`, as the value differs for on-prem vs cloud
});
it('should extend cookie on every successful non-system API call', async () => {

View file

@ -79,9 +79,9 @@ export default function ({ getService }: FtrProviderContext) {
.expect(200);
expect(user.username).to.eql(username);
expect(user.authentication_provider).to.eql('basic');
expect(user.authentication_provider).to.eql({ type: 'basic', name: 'basic' });
expect(user.authentication_type).to.eql('realm');
// Do not assert on the `authentication_realm`, as the value differes for on-prem vs cloud
// Do not assert on the `authentication_realm`, as the value differs for on-prem vs cloud
});
describe('initiating SPNEGO', () => {
@ -146,7 +146,7 @@ export default function ({ getService }: FtrProviderContext) {
enabled: true,
authentication_realm: { name: 'kerb1', type: 'kerberos' },
lookup_realm: { name: 'kerb1', type: 'kerberos' },
authentication_provider: 'kerberos',
authentication_provider: { type: 'kerberos', name: 'kerberos' },
authentication_type: 'token',
});
});

View file

@ -43,7 +43,7 @@ export default function ({ getService }: FtrProviderContext) {
.expect(200);
expect(user.username).to.eql(username);
expect(user.authentication_provider).to.eql('basic');
expect(user.authentication_provider).to.eql({ type: 'basic', name: 'basic' });
expect(user.authentication_type).to.be('realm');
// Do not assert on the `authentication_realm`, as the value differes for on-prem vs cloud
});
@ -235,7 +235,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(apiResponse.body.username).to.be('user1');
expect(apiResponse.body.authentication_realm).to.eql({ name: 'oidc1', type: 'oidc' });
expect(apiResponse.body.authentication_provider).to.eql('oidc');
expect(apiResponse.body.authentication_provider).to.eql({ type: 'oidc', name: 'oidc' });
expect(apiResponse.body.authentication_type).to.be('token');
});
});
@ -289,7 +289,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(apiResponse.body.username).to.be('user2');
expect(apiResponse.body.authentication_realm).to.eql({ name: 'oidc1', type: 'oidc' });
expect(apiResponse.body.authentication_provider).to.eql('oidc');
expect(apiResponse.body.authentication_provider).to.eql({ type: 'oidc', name: 'oidc' });
expect(apiResponse.body.authentication_type).to.be('token');
});
});

View file

@ -156,7 +156,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(apiResponse.body.username).to.be('user1');
expect(apiResponse.body.authentication_realm).to.eql({ name: 'oidc1', type: 'oidc' });
expect(apiResponse.body.authentication_provider).to.eql('oidc');
expect(apiResponse.body.authentication_provider).to.eql({ type: 'oidc', name: 'oidc' });
expect(apiResponse.body.authentication_type).to.be('token');
});
});

View file

@ -93,8 +93,8 @@ export default function ({ getService }: FtrProviderContext) {
.expect(200);
expect(user.username).to.eql(username);
expect(user.authentication_provider).to.eql('basic');
// Do not assert on the `authentication_realm`, as the value differes for on-prem vs cloud
expect(user.authentication_provider).to.eql({ type: 'basic', name: 'basic' });
// Do not assert on the `authentication_realm`, as the value differs for on-prem vs cloud
});
it('should properly set cookie and authenticate user', async () => {
@ -123,7 +123,7 @@ export default function ({ getService }: FtrProviderContext) {
},
authentication_realm: { name: 'pki1', type: 'pki' },
lookup_realm: { name: 'pki1', type: 'pki' },
authentication_provider: 'pki',
authentication_provider: { name: 'pki', type: 'pki' },
authentication_type: 'token',
});
@ -168,7 +168,7 @@ export default function ({ getService }: FtrProviderContext) {
},
authentication_realm: { name: 'pki1', type: 'pki' },
lookup_realm: { name: 'pki1', type: 'pki' },
authentication_provider: 'pki',
authentication_provider: { name: 'pki', type: 'pki' },
authentication_type: 'token',
});

View file

@ -15,16 +15,35 @@ 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_idle')],
services: {
randomness: kibanaAPITestsConfig.get('services.randomness'),
legacyEs: kibanaAPITestsConfig.get('services.legacyEs'),
supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'),
},
servers: xPackAPITestsConfig.get('servers'),
esTestCluster: xPackAPITestsConfig.get('esTestCluster'),
esTestCluster: {
...xPackAPITestsConfig.get('esTestCluster'),
serverArgs: [
...xPackAPITestsConfig.get('esTestCluster.serverArgs'),
'xpack.security.authc.token.enabled=true',
'xpack.security.authc.token.timeout=15s',
'xpack.security.authc.realms.saml.saml1.order=0',
`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'),
@ -32,6 +51,14 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
'--xpack.security.session.idleTimeout=5s',
'--xpack.security.session.cleanupInterval=10s',
`--xpack.security.authc.providers=${JSON.stringify({
basic: { basic1: { order: 0 } },
saml: {
saml_fallback: { order: 1, realm: 'saml1' },
saml_override: { order: 2, realm: 'saml1', session: { idleTimeout: '1m' } },
saml_disable: { order: 3, realm: 'saml1', session: { idleTimeout: 0 } },
},
})}`,
],
},

View file

@ -15,16 +15,35 @@ 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_lifespan')],
services: {
randomness: kibanaAPITestsConfig.get('services.randomness'),
legacyEs: kibanaAPITestsConfig.get('services.legacyEs'),
supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'),
},
servers: xPackAPITestsConfig.get('servers'),
esTestCluster: xPackAPITestsConfig.get('esTestCluster'),
esTestCluster: {
...xPackAPITestsConfig.get('esTestCluster'),
serverArgs: [
...xPackAPITestsConfig.get('esTestCluster.serverArgs'),
'xpack.security.authc.token.enabled=true',
'xpack.security.authc.token.timeout=15s',
'xpack.security.authc.realms.saml.saml1.order=0',
`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'),
@ -32,6 +51,14 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
'--xpack.security.session.lifespan=5s',
'--xpack.security.session.cleanupInterval=10s',
`--xpack.security.authc.providers=${JSON.stringify({
basic: { basic1: { order: 0 } },
saml: {
saml_fallback: { order: 1, realm: 'saml1' },
saml_override: { order: 2, realm: 'saml1', session: { lifespan: '1m' } },
saml_disable: { order: 3, realm: 'saml1', session: { lifespan: 0 } },
},
})}`,
],
},

View file

@ -10,6 +10,7 @@ import { resolve } from 'path';
import url from 'url';
import { CA_CERT_PATH } from '@kbn/dev-utils';
import expect from '@kbn/expect';
import type { AuthenticationProvider } from '../../../../plugins/security/common/types';
import { getStateAndNonce } from '../../../oidc_api_integration/fixtures/oidc_tools';
import {
getMutualAuthenticationResponseToken,
@ -35,7 +36,7 @@ export default function ({ getService }: FtrProviderContext) {
async function checkSessionCookie(
sessionCookie: Cookie,
username: string,
providerName: string,
provider: AuthenticationProvider,
authenticationRealm: { name: string; type: string } | null,
authenticationType: string
) {
@ -66,7 +67,7 @@ export default function ({ getService }: FtrProviderContext) {
]);
expect(apiResponse.body.username).to.be(username);
expect(apiResponse.body.authentication_provider).to.be(providerName);
expect(apiResponse.body.authentication_provider).to.eql(provider);
if (authenticationRealm) {
expect(apiResponse.body.authentication_realm).to.eql(authenticationRealm);
}
@ -146,11 +147,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
request.cookie(cookies[0])!,
'a@b.c',
providerName,
{
name: providerName,
type: 'saml',
},
{ type: 'saml', name: providerName },
{ name: providerName, type: 'saml' },
'token'
);
}
@ -182,11 +180,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
request.cookie(cookies[0])!,
'a@b.c',
providerName,
{
name: providerName,
type: 'saml',
},
{ type: 'saml', name: providerName },
{ name: providerName, type: 'saml' },
'token'
);
}
@ -215,11 +210,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
request.cookie(cookies[0])!,
'a@b.c',
providerName,
{
name: providerName,
type: 'saml',
},
{ type: 'saml', name: providerName },
{ name: providerName, type: 'saml' },
'token'
);
}
@ -244,7 +236,13 @@ export default function ({ getService }: FtrProviderContext) {
)!;
// Skip auth provider check since this comes from the reserved realm,
// which is not available when running on ESS
await checkSessionCookie(basicSessionCookie, 'elastic', 'basic1', null, 'realm');
await checkSessionCookie(
basicSessionCookie,
'elastic',
{ type: 'basic', name: 'basic1' },
null,
'realm'
);
const authenticationResponse = await supertest
.post('/api/security/saml/callback')
@ -267,11 +265,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
request.cookie(cookies[0])!,
'a@b.c',
providerName,
{
name: providerName,
type: 'saml',
},
{ type: 'saml', name: providerName },
{ name: providerName, type: 'saml' },
'token'
);
}
@ -293,11 +288,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
saml1SessionCookie,
'a@b.c',
'saml1',
{
name: 'saml1',
type: 'saml',
},
{ type: 'saml', name: 'saml1' },
{ name: 'saml1', type: 'saml' },
'token'
);
@ -321,11 +313,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
saml2SessionCookie,
'a@b.c',
'saml2',
{
name: 'saml2',
type: 'saml',
},
{ type: 'saml', name: 'saml2' },
{ name: 'saml2', type: 'saml' },
'token'
);
});
@ -346,11 +335,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
saml1SessionCookie,
'a@b.c',
'saml1',
{
name: 'saml1',
type: 'saml',
},
{ type: 'saml', name: 'saml1' },
{ name: 'saml1', type: 'saml' },
'token'
);
@ -376,11 +362,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
saml2SessionCookie,
'a@b.c',
'saml2',
{
name: 'saml2',
type: 'saml',
},
{ type: 'saml', name: 'saml2' },
{ name: 'saml2', type: 'saml' },
'token'
);
});
@ -466,11 +449,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
request.cookie(cookies[0])!,
'a@b.c',
providerName,
{
name: providerName,
type: 'saml',
},
{ type: 'saml', name: providerName },
{ name: providerName, type: 'saml' },
'token'
);
}
@ -537,11 +517,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
saml2SessionCookie,
'a@b.c',
'saml2',
{
name: 'saml2',
type: 'saml',
},
{ type: 'saml', name: 'saml2' },
{ name: 'saml2', type: 'saml' },
'token'
);
});
@ -586,11 +563,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
request.cookie(cookies[0])!,
'tester@TEST.ELASTIC.CO',
'kerberos1',
{
name: 'kerb1',
type: 'kerberos',
},
{ type: 'kerberos', name: 'kerberos1' },
{ name: 'kerb1', type: 'kerberos' },
'token'
);
});
@ -635,11 +609,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
request.cookie(cookies[0])!,
'tester@TEST.ELASTIC.CO',
'kerberos1',
{
name: 'kerb1',
type: 'kerberos',
},
{ type: 'kerberos', name: 'kerberos1' },
{ name: 'kerb1', type: 'kerberos' },
'token'
);
});
@ -677,11 +648,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
request.cookie(cookies[0])!,
'user2',
'oidc1',
{
name: 'oidc1',
type: 'oidc',
},
{ type: 'oidc', name: 'oidc1' },
{ name: 'oidc1', type: 'oidc' },
'token'
);
});
@ -737,11 +705,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
request.cookie(cookies[0])!,
'user1',
'oidc1',
{
name: 'oidc1',
type: 'oidc',
},
{ type: 'oidc', name: 'oidc1' },
{ name: 'oidc1', type: 'oidc' },
'token'
);
});
@ -779,11 +744,8 @@ export default function ({ getService }: FtrProviderContext) {
await checkSessionCookie(
request.cookie(cookies[0])!,
'first_client',
'pki1',
{
name: 'pki1',
type: 'pki',
},
{ type: 'pki', name: 'pki1' },
{ name: 'pki1', type: 'pki' },
'token'
);
});

View file

@ -65,7 +65,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(apiResponse.body.username).to.be(username);
expect(apiResponse.body.authentication_realm).to.eql({ name: 'saml1', type: 'saml' });
expect(apiResponse.body.authentication_provider).to.eql('saml');
expect(apiResponse.body.authentication_provider).to.eql({ type: 'saml', name: 'saml' });
expect(apiResponse.body.authentication_type).to.be('token');
}
@ -97,7 +97,7 @@ export default function ({ getService }: FtrProviderContext) {
.expect(200);
expect(user.username).to.eql(username);
expect(user.authentication_provider).to.eql('basic');
expect(user.authentication_provider).to.eql({ type: 'basic', name: 'basic' });
expect(user.authentication_type).to.be('realm');
// Do not assert on the `authentication_realm`, as the value differes for on-prem vs cloud
});

View file

@ -7,6 +7,8 @@
import request, { Cookie } from 'request';
import { delay } from 'bluebird';
import expect from '@kbn/expect';
import type { AuthenticationProvider } from '../../../../plugins/security/common/types';
import { getSAMLRequestId, getSAMLResponse } from '../../fixtures/saml/saml_tools';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
@ -14,9 +16,15 @@ export default function ({ getService }: FtrProviderContext) {
const es = getService('legacyEs');
const config = getService('config');
const log = getService('log');
const [username, password] = config.get('servers.elasticsearch.auth').split(':');
const randomness = getService('randomness');
const [basicUsername, basicPassword] = config.get('servers.elasticsearch.auth').split(':');
const kibanaServerConfig = config.get('servers.kibana');
async function checkSessionCookie(sessionCookie: Cookie, providerName: string) {
async function checkSessionCookie(
sessionCookie: Cookie,
username: string,
provider: AuthenticationProvider
) {
const apiResponse = await supertest
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
@ -24,9 +32,11 @@ export default function ({ getService }: FtrProviderContext) {
.expect(200);
expect(apiResponse.body.username).to.be(username);
expect(apiResponse.body.authentication_provider).to.be(providerName);
expect(apiResponse.body.authentication_provider).to.eql(provider);
return request.cookie(apiResponse.headers['set-cookie'][0])!;
return Array.isArray(apiResponse.headers['set-cookie'])
? request.cookie(apiResponse.headers['set-cookie'][0])!
: undefined;
}
async function getNumberOfSessionDocuments() {
@ -35,6 +45,31 @@ export default function ({ getService }: FtrProviderContext) {
}).value;
}
async function loginWithSAML(providerName: string) {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ providerType: 'saml', providerName, 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: providerName });
return cookie;
}
describe('Session Idle cleanup', () => {
beforeEach(async () => {
await es.cluster.health({ index: '.kibana_security_session*', waitForStatus: 'green' });
@ -52,14 +87,14 @@ export default function ({ getService }: FtrProviderContext) {
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'basic',
providerName: 'basic',
providerName: 'basic1',
currentURL: '/',
params: { username, password },
params: { username: basicUsername, password: basicPassword },
})
.expect(200);
const sessionCookie = request.cookie(response.headers['set-cookie'][0])!;
await checkSessionCookie(sessionCookie, 'basic');
await checkSessionCookie(sessionCookie, basicUsername, { type: 'basic', name: 'basic1' });
expect(await getNumberOfSessionDocuments()).to.be(1);
// Cleanup routine runs every 10s, and idle timeout threshold is three times larger than 5s
@ -76,6 +111,66 @@ export default function ({ getService }: FtrProviderContext) {
.expect(401);
});
it('should properly clean up session expired because of idle timeout when providers override global session config', async function () {
this.timeout(60000);
const [
samlDisableSessionCookie,
samlOverrideSessionCookie,
samlFallbackSessionCookie,
] = await Promise.all([
loginWithSAML('saml_disable'),
loginWithSAML('saml_override'),
loginWithSAML('saml_fallback'),
]);
const response = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'basic',
providerName: 'basic1',
currentURL: '/',
params: { username: basicUsername, password: basicPassword },
})
.expect(200);
const basicSessionCookie = request.cookie(response.headers['set-cookie'][0])!;
await checkSessionCookie(basicSessionCookie, basicUsername, {
type: 'basic',
name: 'basic1',
});
expect(await getNumberOfSessionDocuments()).to.be(4);
// Cleanup routine runs every 10s, and idle timeout threshold is three times larger than 5s
// idle timeout, let's wait for 30s to make sure cleanup routine runs when idle timeout
// threshold is exceeded.
await delay(30000);
// Session for basic and SAML that used global session settings should not be valid anymore.
expect(await getNumberOfSessionDocuments()).to.be(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', samlFallbackSessionCookie.cookieString())
.expect(401);
// But sessions for the SAML with overridden and disabled lifespan should still be valid.
await checkSessionCookie(samlOverrideSessionCookie, 'a@b.c', {
type: 'saml',
name: 'saml_override',
});
await checkSessionCookie(samlDisableSessionCookie, 'a@b.c', {
type: 'saml',
name: 'saml_disable',
});
});
it('should not clean up session if user is active', async function () {
this.timeout(60000);
@ -84,14 +179,14 @@ export default function ({ getService }: FtrProviderContext) {
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'basic',
providerName: 'basic',
providerName: 'basic1',
currentURL: '/',
params: { username, password },
params: { username: basicUsername, password: basicPassword },
})
.expect(200);
let sessionCookie = request.cookie(response.headers['set-cookie'][0])!;
await checkSessionCookie(sessionCookie, 'basic');
await checkSessionCookie(sessionCookie, basicUsername, { type: 'basic', name: 'basic1' });
expect(await getNumberOfSessionDocuments()).to.be(1);
// Run 20 consequent requests with 1.5s delay, during this time cleanup procedure should run at
@ -100,7 +195,10 @@ export default function ({ getService }: FtrProviderContext) {
// Session idle timeout is 15s, let's wait 10s and make a new request that would extend the session.
await delay(1500);
sessionCookie = await checkSessionCookie(sessionCookie, 'basic');
sessionCookie = (await checkSessionCookie(sessionCookie, basicUsername, {
type: 'basic',
name: 'basic1',
}))!;
log.debug(`Session is still valid after ${(counter + 1) * 1.5}s`);
}

View file

@ -47,7 +47,7 @@ export default function ({ getService }: FtrProviderContext) {
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'basic',
providerName: 'basic',
providerName: 'basic1',
currentURL: '/',
params: { username: validUsername, password: validPassword },
})
@ -61,7 +61,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(body.now).to.be.a('number');
expect(body.idleTimeoutExpiration).to.be.a('number');
expect(body.lifespanExpiration).to.be(null);
expect(body.provider).to.eql({ type: 'basic', name: 'basic' });
expect(body.provider).to.eql({ type: 'basic', name: 'basic1' });
});
it('should not extend the session', async () => {

View file

@ -7,15 +7,23 @@
import request, { Cookie } from 'request';
import { delay } from 'bluebird';
import expect from '@kbn/expect';
import type { AuthenticationProvider } from '../../../../plugins/security/common/types';
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('legacyEs');
const config = getService('config');
const [username, password] = config.get('servers.elasticsearch.auth').split(':');
const randomness = getService('randomness');
const [basicUsername, basicPassword] = config.get('servers.elasticsearch.auth').split(':');
const kibanaServerConfig = config.get('servers.kibana');
async function checkSessionCookie(sessionCookie: Cookie, providerName: string) {
async function checkSessionCookie(
sessionCookie: Cookie,
username: string,
provider: AuthenticationProvider
) {
const apiResponse = await supertest
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
@ -23,7 +31,7 @@ export default function ({ getService }: FtrProviderContext) {
.expect(200);
expect(apiResponse.body.username).to.be(username);
expect(apiResponse.body.authentication_provider).to.be(providerName);
expect(apiResponse.body.authentication_provider).to.eql(provider);
}
async function getNumberOfSessionDocuments() {
@ -32,6 +40,31 @@ export default function ({ getService }: FtrProviderContext) {
}).value;
}
async function loginWithSAML(providerName: string) {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({ providerType: 'saml', providerName, 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: providerName });
return cookie;
}
describe('Session Lifespan cleanup', () => {
beforeEach(async () => {
await es.cluster.health({ index: '.kibana_security_session*', waitForStatus: 'green' });
@ -49,14 +82,17 @@ export default function ({ getService }: FtrProviderContext) {
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'basic',
providerName: 'basic',
providerName: 'basic1',
currentURL: '/',
params: { username, password },
params: { username: basicUsername, password: basicPassword },
})
.expect(200);
const sessionCookie = request.cookie(response.headers['set-cookie'][0])!;
await checkSessionCookie(sessionCookie, 'basic');
await checkSessionCookie(sessionCookie, basicUsername, {
type: 'basic',
name: 'basic1',
});
expect(await getNumberOfSessionDocuments()).to.be(1);
// Cleanup routine runs every 10s, let's wait for 30s to make sure it runs multiple times and
@ -71,5 +107,63 @@ export default function ({ getService }: FtrProviderContext) {
.set('Cookie', sessionCookie.cookieString())
.expect(401);
});
it('should properly clean up session expired because of lifespan when providers override global session config', async function () {
this.timeout(60000);
const [
samlDisableSessionCookie,
samlOverrideSessionCookie,
samlFallbackSessionCookie,
] = await Promise.all([
loginWithSAML('saml_disable'),
loginWithSAML('saml_override'),
loginWithSAML('saml_fallback'),
]);
const response = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'basic',
providerName: 'basic1',
currentURL: '/',
params: { username: basicUsername, password: basicPassword },
})
.expect(200);
const basicSessionCookie = request.cookie(response.headers['set-cookie'][0])!;
await checkSessionCookie(basicSessionCookie, basicUsername, {
type: 'basic',
name: 'basic1',
});
expect(await getNumberOfSessionDocuments()).to.be(4);
// Cleanup routine runs every 10s, let's wait for 30s to make sure it runs multiple times and
// when lifespan is exceeded.
await delay(30000);
// Session for basic and SAML that used global session settings should not be valid anymore.
expect(await getNumberOfSessionDocuments()).to.be(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', samlFallbackSessionCookie.cookieString())
.expect(401);
// But sessions for the SAML with overridden and disabled lifespan should still be valid.
await checkSessionCookie(samlOverrideSessionCookie, 'a@b.c', {
type: 'saml',
name: 'saml_override',
});
await checkSessionCookie(samlDisableSessionCookie, 'a@b.c', {
type: 'saml',
name: 'saml_disable',
});
});
});
}