diff --git a/kibana.d.ts b/kibana.d.ts index 67055c86a4b8635..45cf4405a8edc03 100644 --- a/kibana.d.ts +++ b/kibana.d.ts @@ -53,7 +53,6 @@ export namespace Legacy { export namespace elasticsearch { export type Plugin = LegacyElasticsearch.ElasticsearchPlugin; export type Cluster = LegacyElasticsearch.Cluster; - export type CallClusterWithRequest = LegacyElasticsearch.CallClusterWithRequest; export type ClusterConfig = LegacyElasticsearch.ClusterConfig; export type CallClusterOptions = LegacyElasticsearch.CallClusterOptions; } diff --git a/packages/kbn-config-schema/src/index.ts b/packages/kbn-config-schema/src/index.ts index d86d4c9dca12400..f6f83bfd746643b 100644 --- a/packages/kbn-config-schema/src/index.ts +++ b/packages/kbn-config-schema/src/index.ts @@ -72,7 +72,7 @@ function uri(options?: URIOptions): Type { return new URIType(options); } -function literal(value: T): Type { +function literal(value: T): Type { return new LiteralType(value); } @@ -167,7 +167,7 @@ function siblingRef(key: string): SiblingReference { function conditional( leftOperand: Reference, - rightOperand: Reference | A, + rightOperand: Reference | A | Type, equalType: Type, notEqualType: Type, options?: TypeOptions diff --git a/packages/kbn-config-schema/src/references/reference.ts b/packages/kbn-config-schema/src/references/reference.ts index 5dffc990f3b7bf2..9af1f910053ae6b 100644 --- a/packages/kbn-config-schema/src/references/reference.ts +++ b/packages/kbn-config-schema/src/references/reference.ts @@ -22,7 +22,7 @@ import { internals, Reference as InternalReference } from '../internals'; export class Reference { public static isReference(value: V | Reference | undefined): value is Reference { return ( - value !== undefined && + value != null && typeof (value as Reference).getSchema === 'function' && internals.isRef((value as Reference).getSchema()) ); diff --git a/packages/kbn-config-schema/src/types/conditional_type.ts b/packages/kbn-config-schema/src/types/conditional_type.ts index acba5fa2cedfba0..9f9c86ee942f1ed 100644 --- a/packages/kbn-config-schema/src/types/conditional_type.ts +++ b/packages/kbn-config-schema/src/types/conditional_type.ts @@ -27,13 +27,16 @@ export type ConditionalTypeValue = string | number | boolean | object | null; export class ConditionalType extends Type { constructor( leftOperand: Reference, - rightOperand: Reference | A, + rightOperand: Reference | A | Type, equalType: Type, notEqualType: Type, options?: TypeOptions ) { const schema = internals.when(leftOperand.getSchema(), { - is: Reference.isReference(rightOperand) ? rightOperand.getSchema() : rightOperand, + is: + Reference.isReference(rightOperand) || rightOperand instanceof Type + ? rightOperand.getSchema() + : rightOperand, otherwise: notEqualType.getSchema(), then: equalType.getSchema(), }); diff --git a/src/core/server/config/env.ts b/src/core/server/config/env.ts index 0c877ffe8419617..91271be647c8d18 100644 --- a/src/core/server/config/env.ts +++ b/src/core/server/config/env.ts @@ -29,6 +29,7 @@ export interface PackageInfo { branch: string; buildNum: number; buildSha: string; + dist: boolean; } export interface EnvironmentMode { @@ -137,6 +138,7 @@ export class Env { branch: pkg.branch, buildNum: isKibanaDistributable ? pkg.build.number : Number.MAX_SAFE_INTEGER, buildSha: isKibanaDistributable ? pkg.build.sha : 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + dist: isKibanaDistributable, version: pkg.version, }); } diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index eb571bdb47ddd1d..8bb2a8fd1a4c5f4 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -75,6 +75,7 @@ export interface HttpServerSetup { isAuthenticated: AuthStateStorage['isAuthenticated']; getAuthHeaders: AuthHeadersStorage['get']; }; + isTLSEnabled: boolean; } export class HttpServer { @@ -128,6 +129,7 @@ export class HttpServer { // bridge core and the "legacy" Kibana internally. Once this bridge isn't // needed anymore we shouldn't return the instance from this method. server: this.server, + isTLSEnabled: config.ssl.enabled, }; } diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 2ea0614645c60f1..d6d566bfb57946f 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -45,6 +45,7 @@ const createSetupContractMock = () => { getAuthHeaders: jest.fn(), }, createNewServer: jest.fn(), + isTLSEnabled: false, }; setupContract.createNewServer.mockResolvedValue({} as HttpServerSetup); return setupContract; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 51727b6e02cf134..33221e7d44dfd87 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -35,8 +35,7 @@ * @packageDocumentation */ -import { Observable } from 'rxjs'; -import { ClusterClient, ElasticsearchServiceSetup } from './elasticsearch'; +import { ElasticsearchServiceSetup } from './elasticsearch'; import { HttpServiceSetup, HttpServiceStart } from './http'; import { PluginsServiceSetup, PluginsServiceStart } from './plugins'; @@ -102,7 +101,7 @@ export { SavedObjectsUpdateResponse, } from './saved_objects'; -export { RecursiveReadonly } from '../utils'; +export { RecursiveReadonly, deepFreeze } from '../utils'; /** * Context passed to the plugins `setup` method. @@ -110,16 +109,14 @@ export { RecursiveReadonly } from '../utils'; * @public */ export interface CoreSetup { - elasticsearch: { - adminClient$: Observable; - dataClient$: Observable; - }; + elasticsearch: ElasticsearchServiceSetup; http: { registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; registerAuth: HttpServiceSetup['registerAuth']; registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; basePath: HttpServiceSetup['basePath']; createNewServer: HttpServiceSetup['createNewServer']; + isTLSEnabled: HttpServiceSetup['isTLSEnabled']; }; } diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 10912ca6084856d..bca9b225affb003 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -115,6 +115,8 @@ export function createPluginSetupContext( elasticsearch: { adminClient$: deps.elasticsearch.adminClient$, dataClient$: deps.elasticsearch.dataClient$, + createClient: deps.elasticsearch.createClient, + legacy: deps.elasticsearch.legacy, }, http: { registerOnPreAuth: deps.http.registerOnPreAuth, @@ -122,6 +124,7 @@ export function createPluginSetupContext( registerOnPostAuth: deps.http.registerOnPostAuth, basePath: deps.http.basePath, createNewServer: deps.http.createNewServer, + isTLSEnabled: deps.http.isTLSEnabled, }, }; } diff --git a/src/legacy/core_plugins/elasticsearch/lib/cluster.ts b/src/legacy/core_plugins/elasticsearch/lib/cluster.ts index f7bff02f0281492..b7826d77ba5e156 100644 --- a/src/legacy/core_plugins/elasticsearch/lib/cluster.ts +++ b/src/legacy/core_plugins/elasticsearch/lib/cluster.ts @@ -27,7 +27,7 @@ export class Cluster { constructor(private readonly clusterClient: ClusterClient) {} public callWithRequest = async ( - req: Request | FakeRequest | KibanaRequest, + req: Request | FakeRequest, endpoint: string, clientParams?: Record, options?: CallAPIOptions diff --git a/x-pack/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js index 8b59619fbeafad6..8da771977903117 100644 --- a/x-pack/dev-tools/jest/create_jest_config.js +++ b/x-pack/dev-tools/jest/create_jest_config.js @@ -11,6 +11,7 @@ export function createJestConfig({ return { rootDir: xPackKibanaDirectory, roots: [ + '/plugins', '/legacy/plugins', '/legacy/server', ], diff --git a/x-pack/legacy/plugins/security/__snapshots__/index.test.js.snap b/x-pack/legacy/plugins/security/__snapshots__/index.test.js.snap deleted file mode 100644 index 9a32c69743fab69..000000000000000 --- a/x-pack/legacy/plugins/security/__snapshots__/index.test.js.snap +++ /dev/null @@ -1,54 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`config schema authc oidc realm realm is not allowed when authc.providers is "['basic']" 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because ["oidc" is not allowed]]]`; - -exports[`config schema authc oidc realm returns a validation error when authc.providers is "['oidc', 'basic']" and realm is unspecified 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because ["oidc" is required]]]`; - -exports[`config schema authc oidc realm returns a validation error when authc.providers is "['oidc']" and realm is unspecified 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because ["oidc" is required]]]`; - -exports[`config schema authc oidc realm returns a validation error when authc.providers is "['oidc']" and realm is unspecified 2`] = `[ValidationError: child "authc" fails because [child "oidc" fails because [child "realm" fails because ["realm" is required]]]]`; - -exports[`config schema with context {"dist":false} produces correct config 1`] = ` -Object { - "audit": Object { - "enabled": false, - }, - "authc": Object { - "providers": Array [ - "basic", - ], - }, - "authorization": Object { - "legacyFallback": Object { - "enabled": true, - }, - }, - "cookieName": "sid", - "enabled": true, - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "secureCookies": false, - "sessionTimeout": null, -} -`; - -exports[`config schema with context {"dist":true} produces correct config 1`] = ` -Object { - "audit": Object { - "enabled": false, - }, - "authc": Object { - "providers": Array [ - "basic", - ], - }, - "authorization": Object { - "legacyFallback": Object { - "enabled": true, - }, - }, - "cookieName": "sid", - "enabled": true, - "secureCookies": false, - "sessionTimeout": null, -} -`; diff --git a/x-pack/legacy/plugins/security/common/model/index.ts b/x-pack/legacy/plugins/security/common/model/index.ts index 83648cfb59a67fa..76a8b0a61ea04f3 100644 --- a/x-pack/legacy/plugins/security/common/model/index.ts +++ b/x-pack/legacy/plugins/security/common/model/index.ts @@ -8,5 +8,8 @@ export { Role, RoleIndexPrivilege, RoleKibanaPrivilege } from './role'; export { FeaturesPrivileges } from './features_privileges'; export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; export { KibanaPrivileges } from './kibana_privileges'; -export { User, EditUser, getUserDisplayName } from './user'; -export { AuthenticatedUser, canUserChangePassword } from './authenticated_user'; +export { User, EditUser, getUserDisplayName } from '../../../../../plugins/security/common/model'; +export { + AuthenticatedUser, + canUserChangePassword, +} from '../../../../../plugins/security/common/model'; diff --git a/x-pack/legacy/plugins/security/index.d.ts b/x-pack/legacy/plugins/security/index.d.ts index bc8a09d3598bf34..d01a72b5d4472fa 100644 --- a/x-pack/legacy/plugins/security/index.d.ts +++ b/x-pack/legacy/plugins/security/index.d.ts @@ -6,7 +6,6 @@ import { Legacy } from 'kibana'; import { AuthenticatedUser } from './common/model'; -import { AuthenticationResult, DeauthenticationResult } from './server/lib/authentication'; import { AuthorizationService } from './server/lib/authorization/service'; /** @@ -14,8 +13,6 @@ import { AuthorizationService } from './server/lib/authorization/service'; */ export interface SecurityPlugin { authorization: Readonly; - authenticate: (request: Legacy.Request) => Promise; - deauthenticate: (request: Legacy.Request) => Promise; getUser: (request: Legacy.Request) => Promise; isAuthenticated: (request: Legacy.Request) => Promise; } diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index 395e5d603ba86f6..2f23486e92739af 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -5,7 +5,6 @@ */ import { resolve } from 'path'; -import { getUserProvider } from './server/lib/get_user'; import { initAuthenticateApi } from './server/routes/api/v1/authenticate'; import { initUsersApi } from './server/routes/api/v1/users'; import { initExternalRolesApi } from './server/routes/api/external/roles'; @@ -15,7 +14,6 @@ import { initOverwrittenSessionView } from './server/routes/views/overwritten_se import { initLoginView } from './server/routes/views/login'; import { initLogoutView } from './server/routes/views/logout'; import { initLoggedOutView } from './server/routes/views/logged_out'; -import { validateConfig } from './server/lib/validate_config'; import { initAuthentication } from './server/lib/authentication'; import { checkLicense } from './server/lib/check_license'; import { SecurityAuditLogger } from './server/lib/audit_logger'; @@ -33,6 +31,7 @@ import { SecureSavedObjectsClientWrapper } from './server/lib/saved_objects_clie import { deepFreeze } from './server/lib/deep_freeze'; import { createOptionalPlugin } from '../../server/lib/optional_plugin'; +let defaultVars; export const security = (kibana) => new kibana.Plugin({ id: 'security', configPrefix: 'xpack.security', @@ -40,23 +39,12 @@ export const security = (kibana) => new kibana.Plugin({ require: ['kibana', 'elasticsearch', 'xpack_main'], config(Joi) { - const providerOptionsSchema = (providerName, schema) => Joi.any() - .when('providers', { - is: Joi.array().items(Joi.string().valid(providerName).required(), Joi.string()), - then: schema, - otherwise: Joi.any().forbidden(), - }); - return Joi.object({ enabled: Joi.boolean().default(true), - cookieName: Joi.string().default('sid'), - encryptionKey: Joi.when(Joi.ref('$dist'), { - is: true, - then: Joi.string(), - otherwise: Joi.string().default('a'.repeat(32)), - }), - sessionTimeout: Joi.number().allow(null).default(null), - secureCookies: Joi.boolean().default(false), + cookieName: Joi.any().description('This key is handled in the new platform security plugin ONLY'), + encryptionKey: Joi.any().description('This key is handled in the new platform security plugin ONLY'), + sessionTimeout: Joi.any().description('This key is handled in the new platform security plugin ONLY'), + secureCookies: Joi.any().description('This key is handled in the new platform security plugin ONLY'), authorization: Joi.object({ legacyFallback: Joi.object({ enabled: Joi.boolean().default(true) // deprecated @@ -65,11 +53,7 @@ export const security = (kibana) => new kibana.Plugin({ audit: Joi.object({ enabled: Joi.boolean().default(false) }).default(), - authc: Joi.object({ - providers: Joi.array().items(Joi.string()).default(['basic']), - oidc: providerOptionsSchema('oidc', Joi.object({ realm: Joi.string().required() }).required()), - saml: providerOptionsSchema('saml', Joi.object({ realm: Joi.string().required() }).required()), - }).default() + authc: Joi.any().description('This key is handled in the new platform security plugin ONLY') }).default(); }, @@ -110,15 +94,7 @@ export const security = (kibana) => new kibana.Plugin({ 'plugins/security/hacks/on_unauthorized_response' ], home: ['plugins/security/register_feature'], - injectDefaultVars: function (server) { - const config = server.config(); - - return { - secureCookies: config.get('xpack.security.secureCookies'), - sessionTimeout: config.get('xpack.security.sessionTimeout'), - enableSpaceAwarePrivileges: config.get('xpack.spaces.enabled'), - }; - } + injectDefaultVars: () => defaultVars, }, async postInit(server) { @@ -136,21 +112,37 @@ export const security = (kibana) => new kibana.Plugin({ }, async init(server) { - const plugin = this; + const securityPlugin = this.kbnServer.newPlatform.setup.plugins.security; + if (!securityPlugin) { + throw new Error('New Platform XPack Security plugin is not available.'); + } - const config = server.config(); const xpackMainPlugin = server.plugins.xpack_main; const xpackInfo = xpackMainPlugin.info; + securityPlugin.registerLegacyAPI({ + xpackInfo, + isSystemAPIRequest: server.plugins.kibana.systemApi.isSystemApiRequest.bind( + server.plugins.kibana.systemApi + ), + }); + const plugin = this; + const config = server.config(); const xpackInfoFeature = xpackInfo.feature(plugin.id); + // Config required for default injected vars is coming from new platform plugin and hence we can + // initialize these only within `init` function of the legacy plugin. + defaultVars = { + secureCookies: securityPlugin.config.secureCookies, + sessionTimeout: securityPlugin.config.sessionTimeout, + enableSpaceAwarePrivileges: config.get('xpack.spaces.enabled'), + }; + // Register a function that is called whenever the xpack info changes, // to re-compute the license check results for this plugin xpackInfoFeature.registerLicenseCheckResultsGenerator(checkLicense); - validateConfig(config, message => server.log(['security', 'warning'], message)); - - await initAuthentication(this.kbnServer, server); + server.expose(initAuthentication(securityPlugin)); const { savedObjects } = server; @@ -194,18 +186,16 @@ export const security = (kibana) => new kibana.Plugin({ return client; }); - getUserProvider(server); - - initAuthenticateApi(server); + initAuthenticateApi(securityPlugin, server); initAPIAuthorization(server, authorization); initAppAuthorization(server, xpackMainPlugin, authorization); - initUsersApi(server); + initUsersApi(securityPlugin, server); initExternalRolesApi(server); initIndicesApi(server); initPrivilegesApi(server); - initLoginView(server, xpackMainPlugin); + initLoginView(securityPlugin, server, xpackMainPlugin); initLogoutView(server); - initLoggedOutView(server); + initLoggedOutView(securityPlugin, server); initOverwrittenSessionView(server); server.injectUiAppVars('login', () => { diff --git a/x-pack/legacy/plugins/security/index.test.js b/x-pack/legacy/plugins/security/index.test.js deleted file mode 100644 index 34f95fe798fe82f..000000000000000 --- a/x-pack/legacy/plugins/security/index.test.js +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { security } from './index'; -import { getConfigSchema } from '../../../test_utils'; - -const describeWithContext = describe.each([[{ dist: false }], [{ dist: true }]]); - -describeWithContext('config schema with context %j', context => { - it('produces correct config', async () => { - const schema = await getConfigSchema(security); - await expect(schema.validate({}, { context })).resolves.toMatchSnapshot(); - }); -}); - -describe('config schema', () => { - describe('authc', () => { - describe('oidc', () => { - describe('realm', () => { - it(`returns a validation error when authc.providers is "['oidc']" and realm is unspecified`, async () => { - const schema = await getConfigSchema(security); - expect(schema.validate({ authc: { providers: ['oidc'] } }).error).toMatchSnapshot(); - expect(schema.validate({ authc: { providers: ['oidc'], oidc: {} } }).error).toMatchSnapshot(); - }); - - it(`is valid when authc.providers is "['oidc']" and realm is specified`, async () => { - const schema = await getConfigSchema(security); - const validationResult = schema.validate({ - authc: { - providers: ['oidc'], - oidc: { - realm: 'realm-1', - }, - }, - }); - expect(validationResult.error).toBeNull(); - expect(validationResult.value).toHaveProperty('authc.oidc.realm', 'realm-1'); - }); - - it(`returns a validation error when authc.providers is "['oidc', 'basic']" and realm is unspecified`, async () => { - const schema = await getConfigSchema(security); - const validationResult = schema.validate({ - authc: { providers: ['oidc', 'basic'] }, - }); - expect(validationResult.error).toMatchSnapshot(); - }); - - it(`is valid when authc.providers is "['oidc', 'basic']" and realm is specified`, async () => { - const schema = await getConfigSchema(security); - const validationResult = schema.validate({ - authc: { - providers: ['oidc', 'basic'], - oidc: { - realm: 'realm-1', - }, - }, - }); - expect(validationResult.error).toBeNull(); - expect(validationResult.value).toHaveProperty('authc.oidc.realm', 'realm-1'); - }); - - it(`realm is not allowed when authc.providers is "['basic']"`, async () => { - const schema = await getConfigSchema(security); - const validationResult = schema.validate({ - authc: { - providers: ['basic'], - oidc: { - realm: 'realm-1', - }, - }, - }); - expect(validationResult.error).toMatchSnapshot(); - }); - }); - }); - - describe('saml', () => { - it('fails if authc.providers includes `saml`, but `saml.realm` is not specified', async () => { - const schema = await getConfigSchema(security); - - expect(schema.validate({ authc: { providers: ['saml'] } }).error).toMatchInlineSnapshot( - `[ValidationError: child "authc" fails because [child "saml" fails because ["saml" is required]]]` - ); - expect( - schema.validate({ authc: { providers: ['saml'], saml: {} } }).error - ).toMatchInlineSnapshot( - `[ValidationError: child "authc" fails because [child "saml" fails because [child "realm" fails because ["realm" is required]]]]` - ); - - const validationResult = schema.validate({ - authc: { providers: ['saml'], saml: { realm: 'realm-1' } }, - }); - - expect(validationResult.error).toBeNull(); - expect(validationResult.value.authc.saml.realm).toBe('realm-1'); - }); - - it('`realm` is not allowed if saml provider is not enabled', async () => { - const schema = await getConfigSchema(security); - expect( - schema.validate({ - authc: { - providers: ['basic'], - saml: { realm: 'realm-1' }, - }, - }).error - ).toMatchInlineSnapshot( - `[ValidationError: child "authc" fails because [child "saml" fails because ["saml" is not allowed]]]` - ); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.test.tsx b/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.test.tsx index abd504c86bc518e..221120532318cb9 100644 --- a/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.test.tsx +++ b/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.test.tsx @@ -7,7 +7,7 @@ import { EuiFieldText } from '@elastic/eui'; import { ReactWrapper } from 'enzyme'; import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { User } from '../../../../common/model/user'; +import { User } from '../../../../common/model'; import { UserAPIClient } from '../../../lib/api'; import { ChangePasswordForm } from './change_password_form'; diff --git a/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx b/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx index ad33b977126b453..9521cbdc58a7888 100644 --- a/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx +++ b/x-pack/legacy/plugins/security/public/components/management/change_password_form/change_password_form.tsx @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { ChangeEvent, Component } from 'react'; import { toastNotifications } from 'ui/notify'; -import { User } from '../../../../common/model/user'; +import { User } from '../../../../common/model'; import { UserAPIClient } from '../../../lib/api'; interface Props { diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/request.ts b/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/request.ts index 753325215d37701..c928a38d88ef3ad 100644 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/request.ts +++ b/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/request.ts @@ -5,9 +5,7 @@ */ import { Request } from 'hapi'; -import { stub } from 'sinon'; import url from 'url'; -import { LoginAttempt } from '../../authentication/login_attempt'; interface RequestFixtureOptions { headers?: Record; @@ -24,26 +22,18 @@ export function requestFixture({ auth, params, path = '/wat', - basePath = '', search = '', payload, }: RequestFixtureOptions = {}) { - const cookieAuth = { clear: stub(), set: stub() }; return ({ raw: { req: { headers } }, auth, headers, params, url: { path, search }, - cookieAuth, - getBasePath: () => basePath, - loginAttempt: stub().returns(new LoginAttempt()), query: search ? url.parse(search, true /* parseQueryString */).query : {}, payload, state: { user: 'these are the contents of the user client cookie' }, - } as any) as Request & { - cookieAuth: typeof cookieAuth; - loginAttempt: () => LoginAttempt; - getBasePath: () => string; - }; + route: { settings: {} }, + } as any) as Request; } diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/validate_config.js b/x-pack/legacy/plugins/security/server/lib/__tests__/validate_config.js deleted file mode 100644 index c5a96bc8253f1ab..000000000000000 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/validate_config.js +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import sinon from 'sinon'; -import { validateConfig } from '../validate_config'; - -describe('Validate config', function () { - let config; - const log = sinon.stub(); - const validKey = 'd624dce49dafa1401be7f3e1182b756a'; - - beforeEach(() => { - config = { - get: sinon.stub(), - getDefault: sinon.stub(), - set: sinon.stub(), - }; - log.resetHistory(); - }); - - it('should log a warning and set xpack.security.encryptionKey if not set', function () { - config.get.withArgs('server.ssl.key').returns('foo'); - config.get.withArgs('server.ssl.certificate').returns('bar'); - config.get.withArgs('xpack.security.secureCookies').returns(false); - - expect(() => validateConfig(config, log)).not.to.throwError(); - - sinon.assert.calledWith(config.set, 'xpack.security.encryptionKey'); - sinon.assert.calledWith(config.set, 'xpack.security.secureCookies', true); - sinon.assert.calledWithMatch(log, /Generating a random key/); - sinon.assert.calledWithMatch(log, /please set xpack.security.encryptionKey/); - }); - - it('should throw error if xpack.security.encryptionKey is less than 32 characters', function () { - config.get.withArgs('xpack.security.encryptionKey').returns('foo'); - - const validateConfigFn = () => validateConfig(config); - expect(validateConfigFn).to.throwException(/xpack.security.encryptionKey must be at least 32 characters/); - }); - - it('should log a warning if SSL is not configured', function () { - config.get.withArgs('xpack.security.encryptionKey').returns(validKey); - config.get.withArgs('xpack.security.secureCookies').returns(false); - - expect(() => validateConfig(config, log)).not.to.throwError(); - - sinon.assert.neverCalledWith(config.set, 'xpack.security.encryptionKey'); - sinon.assert.neverCalledWith(config.set, 'xpack.security.secureCookies'); - sinon.assert.calledWithMatch(log, /Session cookies will be transmitted over insecure connections/); - }); - - it('should log a warning if SSL is not configured yet secure cookies are being used', function () { - config.get.withArgs('xpack.security.encryptionKey').returns(validKey); - config.get.withArgs('xpack.security.secureCookies').returns(true); - - expect(() => validateConfig(config, log)).not.to.throwError(); - - sinon.assert.neverCalledWith(config.set, 'xpack.security.encryptionKey'); - sinon.assert.neverCalledWith(config.set, 'xpack.security.secureCookies'); - sinon.assert.calledWithMatch(log, /SSL must be configured outside of Kibana/); - }); - - it('should set xpack.security.secureCookies if SSL is configured', function () { - config.get.withArgs('server.ssl.key').returns('foo'); - config.get.withArgs('server.ssl.certificate').returns('bar'); - config.get.withArgs('xpack.security.encryptionKey').returns(validKey); - - expect(() => validateConfig(config, log)).not.to.throwError(); - - sinon.assert.neverCalledWith(config.set, 'xpack.security.encryptionKey'); - sinon.assert.calledWith(config.set, 'xpack.security.secureCookies', true); - sinon.assert.notCalled(log); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/index.ts b/x-pack/legacy/plugins/security/server/lib/authentication/index.ts index 4d22aeade435c3f..1aa2c3ea8080c14 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/index.ts +++ b/x-pack/legacy/plugins/security/server/lib/authentication/index.ts @@ -4,142 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; -import KbnServer from 'src/legacy/server/kbn_server'; -import Boom from 'boom'; import { Legacy } from 'kibana'; -import { FakeRequest, KibanaRequest } from '../../../../../../../src/core/server'; -import { getClient } from '../../../../../server/lib/get_client_shield'; -import { AuthenticatedUser } from '../../../common/model'; -import { wrapError } from '../errors'; -import { Authenticator, ProviderLoginAttempt, ProviderSession } from './authenticator'; +import { KibanaRequest } from '../../../../../../../src/core/server'; +import { SecuritySetupContract } from '../../../../../../plugins/security/server'; -export { canRedirectRequest } from './can_redirect_request'; -export { AuthenticationResult } from './authentication_result'; - -interface SecurityCallWithRequest extends Legacy.Plugins.elasticsearch.CallClusterWithRequest { - ( - request: KibanaRequest | FakeRequest, - endpoint: 'shield.authenticate', - params?: Record - ): Promise; - ( - request: KibanaRequest, - endpoint: 'shield.getAccessToken', - params: { body: { grant_type: 'client_credentials' } } - ): Promise<{ access_token: string }>; -} - -export interface SecurityClusterClient { - callWithRequest: SecurityCallWithRequest; - callWithInternalUser: Legacy.Plugins.elasticsearch.Cluster['callWithInternalUser']; -} - -export async function initAuthentication(kbnServer: KbnServer, ser: KbnServer['server']) { - const config = kbnServer.server.config(); - const server = kbnServer.server; - - const logger = kbnServer.newPlatform.coreContext.logger; - const core = kbnServer.newPlatform.setup.core; - - const clusterClient = getClient(server) as SecurityClusterClient; - const xpackInfo = (ser as any).plugins.xpack_main.info; - - const authRegistration = await core.http.registerAuth( - async (request, t) => { - // If security is disabled continue with no user credentials - // and delete the client cookie as well. - if (xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled()) { - return t.authenticated(); - } - - let authenticationResult; - try { - authenticationResult = await authenticator.authenticate(request); - } catch (err) { - logger.get('authentication').error(err); - return t.rejected(wrapError(err)); - } - - if (authenticationResult.succeeded()) { - return t.authenticated({ - state: authenticationResult.user, - headers: authenticationResult.authHeaders, - }); - } - - if (authenticationResult.redirected()) { - // Some authentication mechanisms may require user to be redirected to another location to - // initiate or complete authentication flow. It can be Kibana own login page for basic - // authentication (username and password) or arbitrary external page managed by 3rd party - // Identity Provider for SSO authentication mechanisms. Authentication provider is the one who - // decides what location user should be redirected to. - return t.redirected(authenticationResult.redirectURL!); - } - - if (authenticationResult.failed()) { - kbnServer.server.log( - ['info', 'authentication'], - `Authentication attempt failed: ${authenticationResult.error!.message}` - ); - - const error = wrapError(authenticationResult.error); - if (authenticationResult.challenges) { - error.output.headers['WWW-Authenticate'] = authenticationResult.challenges as any; - } - - return t.rejected(error); - } - - return t.rejected(Boom.unauthorized()); - }, - { - encryptionKey: config.get('xpack.security.encryptionKey'), - isSecure: config.get('xpack.security.secureCookies'), - name: config.get('xpack.security.cookieName'), - validate: (sessionValue: ProviderSession) => - !(sessionValue.expires && sessionValue.expires < Date.now()), - } - ); - - const authenticator: Authenticator = new Authenticator({ - clusterClient, - config, - isSystemAPIRequest: server.plugins.kibana.systemApi.isSystemApiRequest.bind( - server.plugins.kibana.systemApi - ), - log: logger, - sessionStorageFactory: authRegistration.sessionStorageFactory, - }); - - const getUser = async (request: Legacy.Request | KibanaRequest) => { - return xpackInfo && xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled() - ? null - : // bad types, it actually accepts KibanaRequest. - clusterClient.callWithRequest(request as any, 'shield.authenticate'); - }; - - ser.expose({ - login: async (request: Legacy.Request, attempt: ProviderLoginAttempt) => - await authenticator.login(KibanaRequest.from(request), attempt), - authenticate: async (request: Legacy.Request) => - await authenticator.authenticate(KibanaRequest.from(request)), - logout: async (request: Legacy.Request) => - await authenticator.logout( - // HACK: remove once https://github.com/elastic/kibana/pull/39448 is merged. - KibanaRequest.from(request, { - query: schema.object({ - msg: schema.maybe(schema.string()), - next: schema.maybe(schema.string()), - SAMLRequest: schema.maybe(schema.any()), - SigAlg: schema.maybe(schema.any()), - Signature: schema.maybe(schema.any()), - }), - }) - ), +export function initAuthentication({ getAuthenticatedUser }: SecuritySetupContract) { + return { isAuthenticated: async (request: Legacy.Request) => { try { - await getUser(request); + await getAuthenticatedUser(KibanaRequest.from(request)); return true; } catch (err) { // Don't swallow server errors. @@ -150,6 +23,8 @@ export async function initAuthentication(kbnServer: KbnServer, ser: KbnServer['s return false; }, - getUser, - }); + + getUser: async (request: Legacy.Request) => + await getAuthenticatedUser(KibanaRequest.from(request)), + }; } diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.mock.ts b/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.mock.ts deleted file mode 100644 index a9132258c75f17a..000000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.mock.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { stub, createStubInstance } from 'sinon'; -import { Tokens } from '../tokens'; -import { AuthenticationProviderOptions } from './base'; - -export function mockAuthenticationProviderOptions( - providerOptions: Partial> = {} -) { - const client = { callWithRequest: stub(), callWithInternalUser: stub() }; - const log = stub(); - - return { - client, - log, - basePath: '/base-path', - tokens: createStubInstance(Tokens), - ...providerOptions, - }; -} diff --git a/x-pack/legacy/plugins/security/server/lib/get_user.ts b/x-pack/legacy/plugins/security/server/lib/get_user.ts deleted file mode 100644 index d0b3321444410f8..000000000000000 --- a/x-pack/legacy/plugins/security/server/lib/get_user.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Legacy } from 'kibana'; -import { getClient } from '../../../../server/lib/get_client_shield'; - -export function getUserProvider(server: any) { - const callWithRequest = getClient(server).callWithRequest; - - server.expose('getUser', async (request: Legacy.Request) => { - const xpackInfo = server.plugins.xpack_main.info; - if (xpackInfo && xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled()) { - return Promise.resolve(null); - } - return await callWithRequest(request, 'shield.authenticate'); - }); -} diff --git a/x-pack/legacy/plugins/security/server/lib/validate_config.js b/x-pack/legacy/plugins/security/server/lib/validate_config.js deleted file mode 100644 index 49c9ba94ffd57fe..000000000000000 --- a/x-pack/legacy/plugins/security/server/lib/validate_config.js +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -const crypto = require('crypto'); - -export function validateConfig(config, log) { - if (config.get('xpack.security.encryptionKey') == null) { - log('Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' + - 'restart, please set xpack.security.encryptionKey in kibana.yml'); - - config.set('xpack.security.encryptionKey', crypto.randomBytes(16).toString('hex')); - } else if (config.get('xpack.security.encryptionKey').length < 32) { - throw new Error('xpack.security.encryptionKey must be at least 32 characters. Please update the key in kibana.yml.'); - } - - const isSslConfigured = config.get('server.ssl.key') != null && config.get('server.ssl.certificate') != null; - if (!isSslConfigured) { - if (config.get('xpack.security.secureCookies')) { - log('Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to ' + - 'function properly.'); - } else { - log('Session cookies will be transmitted over insecure connections. This is not recommended.'); - } - } else { - config.set('xpack.security.secureCookies', true); - } -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.js index ecba6c3f97e6a20..8568321ba194148 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.js +++ b/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.js @@ -5,7 +5,7 @@ */ import Joi from 'joi'; -import { wrapError } from '../../../../lib/errors'; +import { wrapError } from '../../../../../../../../plugins/security/server'; export function initDeleteRolesApi(server, callWithRequest, routePreCheckLicenseFn) { server.route({ diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.js index d0594e32ba48cf6..3540d9b7a883bf0 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.js +++ b/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.js @@ -6,7 +6,7 @@ import _ from 'lodash'; import Boom from 'boom'; import { GLOBAL_RESOURCE, RESERVED_PRIVILEGES_APPLICATION_WILDCARD } from '../../../../../common/constants'; -import { wrapError } from '../../../../lib/errors'; +import { wrapError } from '../../../../../../../../plugins/security/server'; import { PrivilegeSerializer, ResourceSerializer } from '../../../../lib/authorization'; export function initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, application) { diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.js index c04a4f19420a71d..681d2220930ef11 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.js +++ b/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.js @@ -7,7 +7,7 @@ import { flatten, pick, identity, intersection } from 'lodash'; import Joi from 'joi'; import { GLOBAL_RESOURCE } from '../../../../../common/constants'; -import { wrapError } from '../../../../lib/errors'; +import { wrapError } from '../../../../../../../../plugins/security/server'; import { PrivilegeSerializer, ResourceSerializer } from '../../../../lib/authorization'; export function initPutRolesApi( diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js index 5b83881c9772cdd..587e689e4880c00 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/authenticate.js @@ -11,14 +11,15 @@ import sinon from 'sinon'; import { serverFixture } from '../../../../lib/__tests__/__fixtures__/server'; import { requestFixture } from '../../../../lib/__tests__/__fixtures__/request'; -import { AuthenticationResult } from '../../../../../server/lib/authentication/authentication_result'; -import { BasicCredentials } from '../../../../../server/lib/authentication/providers/basic'; +import { AuthenticationResult, DeauthenticationResult } from '../../../../../../../../plugins/security/server'; import { initAuthenticateApi } from '../authenticate'; -import { DeauthenticationResult } from '../../../../lib/authentication/deauthentication_result'; +import { KibanaRequest } from '../../../../../../../../../src/core/server'; describe('Authentication routes', () => { let serverStub; let hStub; + let loginStub; + let logoutStub; beforeEach(() => { serverStub = serverFixture(); @@ -28,14 +29,19 @@ describe('Authentication routes', () => { redirect: sinon.stub(), response: sinon.stub() }; - - initAuthenticateApi(serverStub); + loginStub = sinon.stub(); + logoutStub = sinon.stub(); + + initAuthenticateApi({ + login: loginStub, + logout: logoutStub, + config: { authc: { providers: ['basic'] } }, + }, serverStub); }); describe('login', () => { let loginRoute; let request; - let authenticateStub; beforeEach(() => { loginRoute = serverStub.route @@ -47,10 +53,6 @@ describe('Authentication routes', () => { headers: {}, payload: { username: 'user', password: 'password' } }); - - authenticateStub = serverStub.plugins.security.authenticate.withArgs( - sinon.match(BasicCredentials.decorateRequest(request, 'user', 'password')) - ); }); it('correctly defines route.', async () => { @@ -73,7 +75,7 @@ describe('Authentication routes', () => { it('returns 500 if authentication throws unhandled exception.', async () => { const unhandledException = new Error('Something went wrong.'); - authenticateStub.throws(unhandledException); + loginStub.throws(unhandledException); return loginRoute .handler(request, hStub) @@ -89,7 +91,7 @@ describe('Authentication routes', () => { it('returns 401 if authentication fails.', async () => { const failureReason = new Error('Something went wrong.'); - authenticateStub.returns(Promise.resolve(AuthenticationResult.failed(failureReason))); + loginStub.resolves(AuthenticationResult.failed(failureReason)); return loginRoute .handler(request, hStub) @@ -101,9 +103,7 @@ describe('Authentication routes', () => { }); it('returns 401 if authentication is not handled.', async () => { - authenticateStub.returns( - Promise.resolve(AuthenticationResult.notHandled()) - ); + loginStub.resolves(AuthenticationResult.notHandled()); return loginRoute .handler(request, hStub) @@ -117,14 +117,17 @@ describe('Authentication routes', () => { describe('authentication succeeds', () => { it(`returns user data`, async () => { - const user = { username: 'user' }; - authenticateStub.returns( - Promise.resolve(AuthenticationResult.succeeded(user)) - ); + loginStub.resolves(AuthenticationResult.succeeded({ username: 'user' })); await loginRoute.handler(request, hStub); sinon.assert.calledOnce(hStub.response); + sinon.assert.calledOnce(loginStub); + sinon.assert.calledWithExactly( + loginStub, + sinon.match.instanceOf(KibanaRequest), + { provider: 'basic', value: { username: 'user', password: 'password' } } + ); }); }); @@ -155,9 +158,7 @@ describe('Authentication routes', () => { const request = requestFixture(); const unhandledException = new Error('Something went wrong.'); - serverStub.plugins.security.logout - .withArgs(request) - .returns(Promise.reject(unhandledException)); + logoutStub.rejects(unhandledException); return logoutRoute .handler(request, hStub) @@ -171,15 +172,18 @@ describe('Authentication routes', () => { const request = requestFixture(); const failureReason = Boom.forbidden(); - serverStub.plugins.security.logout - .withArgs(request) - .returns(Promise.resolve(DeauthenticationResult.failed(failureReason))); + logoutStub.resolves(DeauthenticationResult.failed(failureReason)); return logoutRoute .handler(request, hStub) .catch((response) => { expect(response).to.be(Boom.boomify(failureReason)); sinon.assert.notCalled(hStub.redirect); + sinon.assert.calledOnce(logoutStub); + sinon.assert.calledWithExactly( + logoutStub, + sinon.match.instanceOf(KibanaRequest) + ); }); }); @@ -199,11 +203,7 @@ describe('Authentication routes', () => { it('redirects user to the URL returned by authenticator.', async () => { const request = requestFixture(); - serverStub.plugins.security.logout - .withArgs(request) - .returns( - Promise.resolve(DeauthenticationResult.redirectTo('https://custom.logout')) - ); + logoutStub.resolves(DeauthenticationResult.redirectTo('https://custom.logout')); await logoutRoute.handler(request, hStub); @@ -214,9 +214,7 @@ describe('Authentication routes', () => { it('redirects user to the base path if deauthentication succeeds.', async () => { const request = requestFixture(); - serverStub.plugins.security.logout - .withArgs(request) - .returns(Promise.resolve(DeauthenticationResult.succeeded())); + logoutStub.resolves(DeauthenticationResult.succeeded()); await logoutRoute.handler(request, hStub); @@ -227,9 +225,7 @@ describe('Authentication routes', () => { it('redirects user to the base path if deauthentication is not handled.', async () => { const request = requestFixture(); - serverStub.plugins.security.logout - .withArgs(request) - .returns(Promise.resolve(DeauthenticationResult.notHandled())); + logoutStub.resolves(DeauthenticationResult.notHandled()); await logoutRoute.handler(request, hStub); @@ -293,7 +289,7 @@ describe('Authentication routes', () => { it('returns 500 if authentication throws unhandled exception.', async () => { const unhandledException = new Error('Something went wrong.'); - serverStub.plugins.security.authenticate.throws(unhandledException); + loginStub.throws(unhandledException); const response = await samlAcsRoute.handler(request, hStub); @@ -308,9 +304,7 @@ describe('Authentication routes', () => { it('returns 401 if authentication fails.', async () => { const failureReason = new Error('Something went wrong.'); - serverStub.plugins.security.authenticate.returns( - Promise.resolve(AuthenticationResult.failed(failureReason)) - ); + loginStub.resolves(AuthenticationResult.failed(failureReason)); const response = await samlAcsRoute.handler(request, hStub); @@ -321,9 +315,7 @@ describe('Authentication routes', () => { }); it('returns 401 if authentication is not handled.', async () => { - serverStub.plugins.security.authenticate.returns( - Promise.resolve(AuthenticationResult.notHandled()) - ); + loginStub.resolves(AuthenticationResult.notHandled()); const response = await samlAcsRoute.handler(request, hStub); @@ -334,9 +326,7 @@ describe('Authentication routes', () => { }); it('returns 401 if authentication completes with unexpected result.', async () => { - serverStub.plugins.security.authenticate.returns( - Promise.resolve(AuthenticationResult.succeeded({})) - ); + loginStub.resolves(AuthenticationResult.succeeded({})); const response = await samlAcsRoute.handler(request, hStub); @@ -347,9 +337,7 @@ describe('Authentication routes', () => { }); it('redirects if required by the authentication process.', async () => { - serverStub.plugins.security.authenticate.returns( - Promise.resolve(AuthenticationResult.redirectTo('http://redirect-to/path')) - ); + loginStub.resolves(AuthenticationResult.redirectTo('http://redirect-to/path')); await samlAcsRoute.handler(request, hStub); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js index 05c5cad41e2c36f..50125bf7b7e6614 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/__tests__/users.js @@ -10,26 +10,28 @@ import sinon from 'sinon'; import { serverFixture } from '../../../../lib/__tests__/__fixtures__/server'; import { requestFixture } from '../../../../lib/__tests__/__fixtures__/request'; -import { AuthenticationResult } from '../../../../../server/lib/authentication/authentication_result'; -import { BasicCredentials } from '../../../../../server/lib/authentication/providers/basic'; +import { AuthenticationResult, BasicCredentials } from '../../../../../../../../plugins/security/server'; import { initUsersApi } from '../users'; import * as ClientShield from '../../../../../../../server/lib/get_client_shield'; +import { KibanaRequest } from '../../../../../../../../../src/core/server'; describe('User routes', () => { const sandbox = sinon.createSandbox(); let clusterStub; let serverStub; + let loginStub; beforeEach(() => { serverStub = serverFixture(); + loginStub = sinon.stub(); // Cluster is returned by `getClient` function that is wrapped into `once` making cluster // a static singleton, so we should use sandbox to set/reset its behavior between tests. clusterStub = sinon.stub({ callWithRequest() {} }); sandbox.stub(ClientShield, 'getClient').returns(clusterStub); - initUsersApi(serverStub); + initUsersApi({ login: loginStub, config: { authc: { providers: ['basic'] } } }, serverStub); }); afterEach(() => sandbox.restore()); @@ -98,13 +100,12 @@ describe('User routes', () => { it('returns 401 if user can authenticate with new password.', async () => { getUserStub.returns(Promise.resolve({})); - serverStub.plugins.security.authenticate + loginStub .withArgs( - sinon.match(BasicCredentials.decorateRequest(request, 'user', 'new-password')) + sinon.match.instanceOf(KibanaRequest), + { provider: 'basic', value: { username: 'user', password: 'new-password' } } ) - .returns( - Promise.resolve(AuthenticationResult.failed(new Error('Something went wrong.'))) - ); + .resolves(AuthenticationResult.failed(new Error('Something went wrong.'))); return changePasswordRoute .handler(request) @@ -150,13 +151,12 @@ describe('User routes', () => { it('successfully changes own password if provided old password is correct.', async () => { getUserStub.returns(Promise.resolve({})); - serverStub.plugins.security.authenticate + loginStub .withArgs( - sinon.match(BasicCredentials.decorateRequest(request, 'user', 'new-password')) + sinon.match.instanceOf(KibanaRequest), + { provider: 'basic', value: { username: 'user', password: 'new-password' } } ) - .returns( - Promise.resolve(AuthenticationResult.succeeded({})) - ); + .resolves(AuthenticationResult.succeeded({})); const hResponseStub = { code: sinon.stub() }; const hStub = { response: sinon.stub().returns(hResponseStub) }; @@ -190,7 +190,7 @@ describe('User routes', () => { .handler(request) .catch((response) => { sinon.assert.notCalled(serverStub.plugins.security.getUser); - sinon.assert.notCalled(serverStub.plugins.security.authenticate); + sinon.assert.notCalled(loginStub); expect(response.isBoom).to.be(true); expect(response.output.payload).to.eql({ @@ -208,7 +208,7 @@ describe('User routes', () => { await changePasswordRoute.handler(request, hStub); sinon.assert.notCalled(serverStub.plugins.security.getUser); - sinon.assert.notCalled(serverStub.plugins.security.authenticate); + sinon.assert.notCalled(loginStub); sinon.assert.calledOnce(clusterStub.callWithRequest); sinon.assert.calledWithExactly( diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js index 424e21ca3ece0eb..ef7d856b13baa09 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js @@ -6,11 +6,11 @@ import Boom from 'boom'; import Joi from 'joi'; -import { wrapError } from '../../../lib/errors'; -import { canRedirectRequest } from '../../../lib/authentication'; +import { schema } from '@kbn/config-schema'; +import { canRedirectRequest, wrapError } from '../../../../../../../plugins/security/server'; import { KibanaRequest } from '../../../../../../../../src/core/server'; -export function initAuthenticateApi(server) { +export function initAuthenticateApi({ login, logout, config }, server) { server.route({ method: 'POST', @@ -31,9 +31,12 @@ export function initAuthenticateApi(server) { const { username, password } = request.payload; try { - // We should prefer `token` over `basic` iа possible. - const authenticationResult = await server.plugins.security.login(request, { - provider: server.config().get('xpack.security.authc.providers').includes('token') ? 'token' : 'basic', + // We should prefer `token` over `basic` if possible. + const providerToLoginWith = config.authc.providers.includes('token') + ? 'token' + : 'basic'; + const authenticationResult = await login(KibanaRequest.from(request), { + provider: providerToLoginWith, value: { username, password } }); @@ -57,13 +60,13 @@ export function initAuthenticateApi(server) { payload: Joi.object({ SAMLResponse: Joi.string().required(), RelayState: Joi.string().allow('') - }).required() + }) } }, async handler(request, h) { try { // When authenticating using SAML we _expect_ to redirect to the SAML Identity provider. - const authenticationResult = await server.plugins.security.login(request, { + const authenticationResult = await login(KibanaRequest.from(request), { provider: 'saml', value: { samlResponse: request.payload.SAMLResponse } }); @@ -102,7 +105,7 @@ export function initAuthenticateApi(server) { try { // We handle the fact that the user might get redirected to Kibana while already having an session // Return an error notifying the user they are already logged in. - const authenticationResult = await server.plugins.security.login(request, { + const authenticationResult = await login(KibanaRequest.from(request), { provider: 'oidc', // Checks if the request object represents an HTTP request regarding authentication with OpenID Connect. // This can be @@ -150,7 +153,11 @@ export function initAuthenticateApi(server) { } try { - const deauthenticationResult = await server.plugins.security.logout(request); + const deauthenticationResult = await logout( + KibanaRequest.from(request, { + query: schema.object({}, { allowUnknowns: true }), + }) + ); if (deauthenticationResult.failed()) { throw wrapError(deauthenticationResult.error); } diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/indices.js b/x-pack/legacy/plugins/security/server/routes/api/v1/indices.js index 2625899b09f4958..7265b83783fdd28 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/indices.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/indices.js @@ -6,7 +6,7 @@ import _ from 'lodash'; import { getClient } from '../../../../../../server/lib/get_client_shield'; -import { wrapError } from '../../../lib/errors'; +import { wrapError } from '../../../../../../../plugins/security/server'; export function initIndicesApi(server) { const callWithRequest = getClient(server).callWithRequest; diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js index 0e2719e87707dac..21ce8393c260631 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js @@ -9,11 +9,11 @@ import Boom from 'boom'; import Joi from 'joi'; import { getClient } from '../../../../../../server/lib/get_client_shield'; import { userSchema } from '../../../lib/user_schema'; -import { wrapError } from '../../../lib/errors'; import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; -import { BasicCredentials } from '../../../../server/lib/authentication/providers/basic'; +import { BasicCredentials, wrapError } from '../../../../../../../plugins/security/server'; +import { KibanaRequest } from '../../../../../../../../src/core/server'; -export function initUsersApi(server) { +export function initUsersApi({ login, config }, server) { const callWithRequest = getClient(server).callWithRequest; const routePreCheckLicenseFn = routePreCheckLicense(server); @@ -105,8 +105,14 @@ export function initUsersApi(server) { // Now we authenticate user with the new password again updating current session if any. if (isCurrentUser) { - request.loginAttempt().setCredentials(username, newPassword); - const authenticationResult = await server.plugins.security.authenticate(request); + // We should prefer `token` over `basic` if possible. + const providerToLoginWith = config.authc.providers.includes('token') + ? 'token' + : 'basic'; + const authenticationResult = await login(KibanaRequest.from(request), { + provider: providerToLoginWith, + value: { username, password: newPassword } + }); if (!authenticationResult.succeeded()) { throw Boom.unauthorized((authenticationResult.error)); diff --git a/x-pack/legacy/plugins/security/server/routes/views/logged_out.js b/x-pack/legacy/plugins/security/server/routes/views/logged_out.js index 7a0a1da9ed0d3d1..51867631b57bee7 100644 --- a/x-pack/legacy/plugins/security/server/routes/views/logged_out.js +++ b/x-pack/legacy/plugins/security/server/routes/views/logged_out.js @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export function initLoggedOutView(server) { +export function initLoggedOutView({ config: { cookieName } }, server) { const config = server.config(); const loggedOut = server.getHiddenUiAppById('logged_out'); - const cookieName = config.get('xpack.security.cookieName'); server.route({ method: 'GET', diff --git a/x-pack/legacy/plugins/security/server/routes/views/login.js b/x-pack/legacy/plugins/security/server/routes/views/login.js index 95c0c56ed6ad565..f7e7f2933efcc6a 100644 --- a/x-pack/legacy/plugins/security/server/routes/views/login.js +++ b/x-pack/legacy/plugins/security/server/routes/views/login.js @@ -8,9 +8,8 @@ import { get } from 'lodash'; import { parseNext } from '../../lib/parse_next'; -export function initLoginView(server, xpackMainPlugin) { +export function initLoginView({ config: { cookieName } }, server, xpackMainPlugin) { const config = server.config(); - const cookieName = config.get('xpack.security.cookieName'); const login = server.getHiddenUiAppById('login'); function shouldShowLogin() { diff --git a/x-pack/package.json b/x-pack/package.json index 4b5c1efd6fe1857..22e30c56e5bbf44 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -177,6 +177,7 @@ "@elastic/numeral": "2.3.3", "@elastic/request-crypto": "^1.0.2", "@kbn/babel-preset": "1.0.0", + "@kbn/config-schema": "1.0.0", "@kbn/elastic-idx": "1.0.0", "@kbn/es-query": "1.0.0", "@kbn/i18n": "1.0.0", @@ -237,7 +238,6 @@ "graphql-tools": "^3.0.2", "h2o2": "^8.1.2", "handlebars": "^4.0.14", - "hapi-auth-cookie": "^9.0.0", "history": "4.9.0", "history-extra": "^5.0.1", "humps": "2.0.1", diff --git a/x-pack/legacy/plugins/security/common/model/authenticated_user.test.ts b/x-pack/plugins/security/common/model/authenticated_user.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/authenticated_user.test.ts rename to x-pack/plugins/security/common/model/authenticated_user.test.ts diff --git a/x-pack/legacy/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/authenticated_user.ts rename to x-pack/plugins/security/common/model/authenticated_user.ts diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts new file mode 100644 index 000000000000000..00b17548c47ac2d --- /dev/null +++ b/x-pack/plugins/security/common/model/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { User, EditUser, getUserDisplayName } from './user'; +export { AuthenticatedUser, canUserChangePassword } from './authenticated_user'; diff --git a/x-pack/legacy/plugins/security/common/model/user.test.ts b/x-pack/plugins/security/common/model/user.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/user.test.ts rename to x-pack/plugins/security/common/model/user.test.ts diff --git a/x-pack/legacy/plugins/security/common/model/user.ts b/x-pack/plugins/security/common/model/user.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/user.ts rename to x-pack/plugins/security/common/model/user.ts diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json new file mode 100644 index 000000000000000..7ac9d654eb07ee3 --- /dev/null +++ b/x-pack/plugins/security/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "security", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack", "security"], + "server": true, + "ui": false +} diff --git a/x-pack/plugins/security/server/__fixtures__/index.ts b/x-pack/plugins/security/server/__fixtures__/index.ts new file mode 100644 index 000000000000000..c8f0d6dee273f37 --- /dev/null +++ b/x-pack/plugins/security/server/__fixtures__/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { requestFixture } from './request'; diff --git a/x-pack/plugins/security/server/__fixtures__/request.ts b/x-pack/plugins/security/server/__fixtures__/request.ts new file mode 100644 index 000000000000000..96becbc12985004 --- /dev/null +++ b/x-pack/plugins/security/server/__fixtures__/request.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import url from 'url'; +import { KibanaRequest } from '../../../../../src/core/server'; + +interface RequestFixtureOptions { + headers?: Record; + params?: Record; + path?: string; + search?: string; + payload?: unknown; +} + +export function requestFixture({ + headers = { accept: 'something/html' }, + params, + path = '/wat', + search = '', + payload, +}: RequestFixtureOptions = {}) { + return KibanaRequest.from({ + headers, + params, + url: { path, search }, + query: search ? url.parse(search, true /* parseQueryString */).query : {}, + payload, + route: { settings: {} }, + } as any); +} diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.test.ts b/x-pack/plugins/security/server/authentication/authentication_result.test.ts similarity index 69% rename from x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.test.ts rename to x-pack/plugins/security/server/authentication/authentication_result.test.ts index b226ddd7d8b9a09..7bde5a8111237c9 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.test.ts +++ b/x-pack/plugins/security/server/authentication/authentication_result.test.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { AuthenticatedUser } from '../../../common/model'; +import { AuthenticatedUser } from '../../common/model'; import { AuthenticationResult } from './authentication_result'; describe('AuthenticationResult', () => { @@ -21,6 +21,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); }); @@ -44,6 +45,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.error).toBe(failureReason); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); @@ -60,6 +62,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.error).toBe(failureReason); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); @@ -77,7 +80,7 @@ describe('AuthenticationResult', () => { ); }); - it('correctly produces `succeeded` authentication result without state.', () => { + it('correctly produces `succeeded` authentication result without state and authHeaders.', () => { const user = { username: 'user' } as AuthenticatedUser; const authenticationResult = AuthenticationResult.succeeded(user); @@ -88,14 +91,15 @@ describe('AuthenticationResult', () => { expect(authenticationResult.user).toBe(user); expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); - it('correctly produces `succeeded` authentication result with state.', () => { + it('correctly produces `succeeded` authentication result with state, but without authHeaders.', () => { const user = { username: 'user' } as AuthenticatedUser; const state = { some: 'state' }; - const authenticationResult = AuthenticationResult.succeeded(user, state); + const authenticationResult = AuthenticationResult.succeeded(user, { state }); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.failed()).toBe(false); @@ -104,6 +108,42 @@ describe('AuthenticationResult', () => { expect(authenticationResult.user).toBe(user); expect(authenticationResult.state).toBe(state); + expect(authenticationResult.authHeaders).toBeUndefined(); + expect(authenticationResult.error).toBeUndefined(); + expect(authenticationResult.redirectURL).toBeUndefined(); + }); + + it('correctly produces `succeeded` authentication result with authHeaders, but without state.', () => { + const user = { username: 'user' } as AuthenticatedUser; + const authHeaders = { authorization: 'some-token' }; + const authenticationResult = AuthenticationResult.succeeded(user, { authHeaders }); + + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.failed()).toBe(false); + expect(authenticationResult.notHandled()).toBe(false); + expect(authenticationResult.redirected()).toBe(false); + + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBe(authHeaders); + expect(authenticationResult.error).toBeUndefined(); + expect(authenticationResult.redirectURL).toBeUndefined(); + }); + + it('correctly produces `succeeded` authentication result with both authHeaders and state.', () => { + const user = { username: 'user' } as AuthenticatedUser; + const authHeaders = { authorization: 'some-token' }; + const state = { some: 'state' }; + const authenticationResult = AuthenticationResult.succeeded(user, { authHeaders, state }); + + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.failed()).toBe(false); + expect(authenticationResult.notHandled()).toBe(false); + expect(authenticationResult.redirected()).toBe(false); + + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.state).toBe(state); + expect(authenticationResult.authHeaders).toBe(authHeaders); expect(authenticationResult.error).toBeUndefined(); expect(authenticationResult.redirectURL).toBeUndefined(); }); @@ -128,6 +168,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.redirectURL).toBe(redirectURL); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); }); @@ -143,6 +184,7 @@ describe('AuthenticationResult', () => { expect(authenticationResult.redirectURL).toBe(redirectURL); expect(authenticationResult.state).toBe(state); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); }); @@ -177,20 +219,30 @@ describe('AuthenticationResult', () => { it('depends on `state` for `succeeded`.', () => { const mockUser = { username: 'u' } as AuthenticatedUser; - expect(AuthenticationResult.succeeded(mockUser, 'string').shouldUpdateState()).toBe(true); - expect(AuthenticationResult.succeeded(mockUser, 0).shouldUpdateState()).toBe(true); - expect(AuthenticationResult.succeeded(mockUser, true).shouldUpdateState()).toBe(true); - expect(AuthenticationResult.succeeded(mockUser, false).shouldUpdateState()).toBe(true); - expect(AuthenticationResult.succeeded(mockUser, { prop: 'object' }).shouldUpdateState()).toBe( + expect( + AuthenticationResult.succeeded(mockUser, { state: 'string' }).shouldUpdateState() + ).toBe(true); + expect(AuthenticationResult.succeeded(mockUser, { state: 0 }).shouldUpdateState()).toBe(true); + expect(AuthenticationResult.succeeded(mockUser, { state: true }).shouldUpdateState()).toBe( true ); - expect(AuthenticationResult.succeeded(mockUser, { prop: 'object' }).shouldUpdateState()).toBe( + expect(AuthenticationResult.succeeded(mockUser, { state: false }).shouldUpdateState()).toBe( true ); + expect( + AuthenticationResult.succeeded(mockUser, { state: { prop: 'object' } }).shouldUpdateState() + ).toBe(true); + expect( + AuthenticationResult.succeeded(mockUser, { state: { prop: 'object' } }).shouldUpdateState() + ).toBe(true); expect(AuthenticationResult.succeeded(mockUser).shouldUpdateState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, undefined).shouldUpdateState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, null).shouldUpdateState()).toBe(false); + expect( + AuthenticationResult.succeeded(mockUser, { state: undefined }).shouldUpdateState() + ).toBe(false); + expect(AuthenticationResult.succeeded(mockUser, { state: null }).shouldUpdateState()).toBe( + false + ); }); }); @@ -223,20 +275,30 @@ describe('AuthenticationResult', () => { it('depends on `state` for `succeeded`.', () => { const mockUser = { username: 'u' } as AuthenticatedUser; - expect(AuthenticationResult.succeeded(mockUser, null).shouldClearState()).toBe(true); + expect(AuthenticationResult.succeeded(mockUser, { state: null }).shouldClearState()).toBe( + true + ); expect(AuthenticationResult.succeeded(mockUser).shouldClearState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, undefined).shouldClearState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, 'string').shouldClearState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, 0).shouldClearState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, true).shouldClearState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, false).shouldClearState()).toBe(false); - expect(AuthenticationResult.succeeded(mockUser, { prop: 'object' }).shouldClearState()).toBe( + expect( + AuthenticationResult.succeeded(mockUser, { state: undefined }).shouldClearState() + ).toBe(false); + expect(AuthenticationResult.succeeded(mockUser, { state: 'string' }).shouldClearState()).toBe( + false + ); + expect(AuthenticationResult.succeeded(mockUser, { state: 0 }).shouldClearState()).toBe(false); + expect(AuthenticationResult.succeeded(mockUser, { state: true }).shouldClearState()).toBe( false ); - expect(AuthenticationResult.succeeded(mockUser, { prop: 'object' }).shouldClearState()).toBe( + expect(AuthenticationResult.succeeded(mockUser, { state: false }).shouldClearState()).toBe( false ); + expect( + AuthenticationResult.succeeded(mockUser, { state: { prop: 'object' } }).shouldClearState() + ).toBe(false); + expect( + AuthenticationResult.succeeded(mockUser, { state: { prop: 'object' } }).shouldClearState() + ).toBe(false); }); }); }); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.ts b/x-pack/plugins/security/server/authentication/authentication_result.ts similarity index 96% rename from x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.ts rename to x-pack/plugins/security/server/authentication/authentication_result.ts index 62a430f14aba4c5..47c4227787ce4f3 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/authentication_result.ts +++ b/x-pack/plugins/security/server/authentication/authentication_result.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AuthenticatedUser } from '../../common/model'; /** * Represents status that `AuthenticationResult` can be in. */ -import { AuthenticatedUser } from '../../../common/model'; import { getErrorStatusCode } from '../errors'; enum AuthenticationResultStatus { @@ -63,13 +63,12 @@ export class AuthenticationResult { /** * Produces `AuthenticationResult` for the case when authentication succeeds. * @param user User information retrieved as a result of successful authentication attempt. - * @param authHeaders The dictionary of headers with authentication information. + * @param [authHeaders] Optional dictionary of the HTTP headers with authentication information. * @param [state] Optional state to be stored and reused for the next request. */ public static succeeded( user: AuthenticatedUser, - authHeaders: Record = {}, - state?: unknown + { authHeaders, state }: { authHeaders?: Record; state?: unknown } = {} ) { if (!user) { throw new Error('User should be specified.'); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/authenticator.test.ts rename to x-pack/plugins/security/server/authentication/authenticator.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts similarity index 90% rename from x-pack/legacy/plugins/security/server/lib/authentication/authenticator.ts rename to x-pack/plugins/security/server/authentication/authenticator.ts index c8d8613e56aca00..c9dd8e8d9ab55c3 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -4,16 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; import { SessionStorageFactory, SessionStorage, KibanaRequest, LoggerFactory, Logger, -} from '../../../../../../../src/core/server'; + HttpServiceSetup, + ClusterClient, +} from '../../../../../src/core/server'; +import { ConfigType } from '../config'; import { getErrorStatusCode } from '../errors'; -import { SecurityClusterClient } from '.'; import { AuthenticationProviderOptions, @@ -54,9 +55,10 @@ export interface ProviderLoginAttempt { } interface AuthenticatorOptions { - config: Legacy.KibanaConfig; - log: LoggerFactory; - clusterClient: SecurityClusterClient; + config: Pick; + basePath: HttpServiceSetup['basePath']; + loggers: LoggerFactory; + clusterClient: ClusterClient; sessionStorageFactory: SessionStorageFactory; isSystemAPIRequest: (request: KibanaRequest) => boolean; } @@ -135,53 +137,43 @@ export class Authenticator { /** * Internal authenticator logger. */ - private readonly log: Logger; + private readonly logger: Logger; /** * Instantiates Authenticator and bootstrap configured providers. * @param options Authenticator options. */ constructor(private readonly options: Readonly) { - this.log = options.log.get('security', 'authenticator'); - - const authProviders = this.options.config.get('xpack.security.authc.providers'); - if (authProviders.length === 0) { - throw new Error( - 'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.' - ); - } + this.logger = options.loggers.get('authenticator'); const providerCommonOptions = { client: this.options.clusterClient, - basePath: this.options.config.get('server.basePath'), + basePath: this.options.basePath, tokens: new Tokens({ client: this.options.clusterClient, - log: this.options.log.get('tokens'), + logger: this.options.loggers.get('tokens'), }), }; + const authProviders = this.options.config.authc.providers; this.providers = new Map( authProviders.map(providerType => { - const providerOptionsConfigKey = `xpack.security.authc.${providerType}`; - const providerSpecificOptions = this.options.config.has(providerOptionsConfigKey) - ? this.options.config.get(providerOptionsConfigKey) + const providerSpecificOptions = this.options.config.authc.hasOwnProperty(providerType) + ? (this.options.config.authc as Record)[providerType] : undefined; return [ providerType, instantiateProvider( providerType, - Object.freeze({ - ...providerCommonOptions, - log: this.options.log.get('security', providerType), - }), + Object.freeze({ ...providerCommonOptions, logger: options.loggers.get(providerType) }), providerSpecificOptions ), ] as [string, BaseAuthenticationProvider]; }) ); - this.ttl = this.options.config.get('xpack.security.sessionTimeout'); + this.ttl = this.options.config.sessionTimeout; } /** @@ -196,13 +188,13 @@ export class Authenticator { // If there is an attempt to login with a provider that isn't enabled, we should fail. const provider = this.providers.get(loginAttempt.provider); if (provider === undefined) { - this.log.debug( + this.logger.debug( `Login attempt for provider "${loginAttempt.provider}" is detected, but it isn't enabled.` ); return AuthenticationResult.notHandled(); } - this.log.debug(`Performing login using "${loginAttempt.provider}" provider.`); + this.logger.debug(`Performing login using "${loginAttempt.provider}" provider.`); const sessionStorage = this.options.sessionStorageFactory.asScoped(request); @@ -210,7 +202,7 @@ export class Authenticator { // perform a login we should clear such session. let existingSession = await this.getSessionValue(sessionStorage); if (existingSession && existingSession.provider !== loginAttempt.provider) { - this.log.debug( + this.logger.debug( `Clearing existing session of another ("${existingSession.provider}") provider.` ); await sessionStorage.clear(); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/can_redirect_request.test.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts similarity index 70% rename from x-pack/legacy/plugins/security/server/lib/authentication/can_redirect_request.test.ts rename to x-pack/plugins/security/server/authentication/can_redirect_request.test.ts index 0328e88648ec5ee..5d19ee7ac9a7fa5 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/can_redirect_request.test.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.test.ts @@ -3,28 +3,23 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -/* -import { requestFixture } from './__tests__/__fixtures__/request'; +import { requestFixture } from '../__fixtures__'; import { canRedirectRequest } from './can_redirect_request'; -describe('lib/can_redirect_request', () => { +describe('can_redirect_request', () => { it('returns true if request does not have either a kbn-version or kbn-xsrf header', () => { expect(canRedirectRequest(requestFixture())).toBe(true); }); it('returns false if request has a kbn-version header', () => { - const request = requestFixture(); - request.raw.req.headers['kbn-version'] = 'something'; - + const request = requestFixture({ headers: { 'kbn-version': 'something' } }); expect(canRedirectRequest(request)).toBe(false); }); it('returns false if request has a kbn-xsrf header', () => { - const request = requestFixture(); - request.raw.req.headers['kbn-xsrf'] = 'something'; + const request = requestFixture({ headers: { 'kbn-xsrf': 'something' } }); expect(canRedirectRequest(request)).toBe(false); }); }); -*/ diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/can_redirect_request.ts b/x-pack/plugins/security/server/authentication/can_redirect_request.ts similarity index 93% rename from x-pack/legacy/plugins/security/server/lib/authentication/can_redirect_request.ts rename to x-pack/plugins/security/server/authentication/can_redirect_request.ts index d1a233b29500726..7e2b2e5eaf9f532 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/can_redirect_request.ts +++ b/x-pack/plugins/security/server/authentication/can_redirect_request.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest } from 'src/core/server/http/router'; +import { KibanaRequest } from '../../../../../src/core/server'; const ROUTE_TAG_API = 'api'; const KIBANA_XSRF_HEADER = 'kbn-xsrf'; diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/deauthentication_result.test.ts b/x-pack/plugins/security/server/authentication/deauthentication_result.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/deauthentication_result.test.ts rename to x-pack/plugins/security/server/authentication/deauthentication_result.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/deauthentication_result.ts b/x-pack/plugins/security/server/authentication/deauthentication_result.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/deauthentication_result.ts rename to x-pack/plugins/security/server/authentication/deauthentication_result.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/auth_redirect.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/auth_redirect.test.ts rename to x-pack/plugins/security/server/authentication/index.test.ts diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts new file mode 100644 index 000000000000000..f5a83805bc0e571 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { + ClusterClient, + CoreSetup, + KibanaRequest, + LoggerFactory, +} from '../../../../../src/core/server'; +import { ConfigType } from '../config'; +import { wrapError } from '../errors'; +import { LegacyAPI } from '..'; +import { Authenticator, ProviderSession } from './authenticator'; + +export { canRedirectRequest } from './can_redirect_request'; +export { Authenticator, ProviderLoginAttempt } from './authenticator'; +export { AuthenticationResult } from './authentication_result'; +export { DeauthenticationResult } from './deauthentication_result'; +export { BasicCredentials } from './providers'; + +export async function setupAuthentication({ + core, + clusterClient, + config, + loggers, + getLegacyAPI, +}: { + core: CoreSetup; + clusterClient: ClusterClient; + config: ConfigType; + loggers: LoggerFactory; + getLegacyAPI(): LegacyAPI; +}) { + const authLogger = loggers.get('authentication'); + let authenticator: Authenticator | undefined; + + const authRegistration = await core.http.registerAuth( + async (request, t) => { + if (!authenticator) { + throw new Error('Authenticator is not initialized!'); + } + + // If security is disabled continue with no user credentials + // and delete the client cookie as well. + const xpackInfo = getLegacyAPI().xpackInfo; + if (xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled()) { + return t.authenticated(); + } + + let authenticationResult; + try { + authenticationResult = await authenticator.authenticate(request); + } catch (err) { + authLogger.error(err); + return t.rejected(wrapError(err)); + } + + if (authenticationResult.succeeded()) { + return t.authenticated({ + state: authenticationResult.user, + headers: authenticationResult.authHeaders, + }); + } + + if (authenticationResult.redirected()) { + // Some authentication mechanisms may require user to be redirected to another location to + // initiate or complete authentication flow. It can be Kibana own login page for basic + // authentication (username and password) or arbitrary external page managed by 3rd party + // Identity Provider for SSO authentication mechanisms. Authentication provider is the one who + // decides what location user should be redirected to. + return t.redirected(authenticationResult.redirectURL!); + } + + if (authenticationResult.failed()) { + authLogger.info(`Authentication attempt failed: ${authenticationResult.error!.message}`); + + const error = wrapError(authenticationResult.error); + if (authenticationResult.challenges) { + error.output.headers['WWW-Authenticate'] = authenticationResult.challenges as any; + } + + return t.rejected(error); + } + + return t.rejected(Boom.unauthorized()); + }, + { + encryptionKey: config.encryptionKey, + isSecure: config.secureCookies, + name: config.cookieName, + validate: (sessionValue: ProviderSession) => + !(sessionValue.expires && sessionValue.expires < Date.now()), + } + ); + + return (authenticator = new Authenticator({ + clusterClient, + basePath: core.http.basePath, + config: { sessionTimeout: config.sessionTimeout, authc: config.authc }, + isSystemAPIRequest: (request: KibanaRequest) => getLegacyAPI().isSystemAPIRequest(request), + loggers, + sessionStorageFactory: authRegistration.sessionStorageFactory, + })); +} diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts new file mode 100644 index 000000000000000..edce7d4dc0f85b7 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { stub, createStubInstance } from 'sinon'; +import { Tokens } from '../tokens'; + +export function mockAuthenticationProviderOptions() { + return { + client: { callAsInternalUser: stub(), asScoped: stub(), close: stub() }, + logger: { + debug: stub(), + error: stub(), + fatal: stub(), + info: stub(), + log: stub(), + trace: stub(), + warn: stub(), + }, + basePath: { + get: stub().returns('/base-path'), + set: stub(), + remove: stub(), + prepend: stub(), + }, + tokens: createStubInstance(Tokens), + }; +} diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts similarity index 68% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/base.ts rename to x-pack/plugins/security/server/authentication/providers/base.ts index fc97472d088f3f0..afe14383a766f16 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -4,19 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest, Logger } from '../../../../../../../../src/core/server'; +import { + KibanaRequest, + Logger, + HttpServiceSetup, + ClusterClient, + Headers, +} from '../../../../../../src/core/server'; +import { AuthenticatedUser } from '../../../common/model'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { SecurityClusterClient } from '../index'; import { Tokens } from '../tokens'; /** * Represents available provider options. */ export interface AuthenticationProviderOptions { - basePath: string; - client: SecurityClusterClient; - log: Logger; + basePath: HttpServiceSetup['basePath']; + client: PublicMethodsOf; + logger: Logger; tokens: PublicMethodsOf; } @@ -29,11 +35,18 @@ export type AuthenticationProviderSpecificOptions = Record; * Base class that all authentication providers should extend. */ export abstract class BaseAuthenticationProvider { + /** + * Logger instance bound to a specific provider context. + */ + protected readonly logger: Logger; + /** * Instantiates AuthenticationProvider. * @param options Provider options object. */ - constructor(protected readonly options: Readonly) {} + constructor(protected readonly options: Readonly) { + this.logger = options.logger; + } /** * Performs initial login request and creates user session. Provider isn't required to implement @@ -64,4 +77,16 @@ export abstract class BaseAuthenticationProvider { * @param [state] Optional state object associated with the provider that needs to be invalidated. */ abstract logout(request: KibanaRequest, state?: unknown): Promise; + + /** + * Queries Elasticsearch `_authenticate` endpoint to authenticate request and retrieve the user + * information of authenticated user. + * @param request Request instance. + * @param [authHeaders] Optional `Headers` dictionary to send with the request. + */ + protected async getUser(request: KibanaRequest, authHeaders: Headers = {}) { + return (await this.options.client + .asScoped({ headers: { ...(request.headers as Record), ...authHeaders } }) + .callAsCurrentUser('shield.authenticate')) as AuthenticatedUser; + } } diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts similarity index 62% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.test.ts rename to x-pack/plugins/security/server/authentication/providers/basic.test.ts index 594475c654a6a33..4f571ebaf2a72a6 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -5,8 +5,8 @@ */ import sinon from 'sinon'; -import { requestFixture } from '../../__tests__/__fixtures__/request'; -import { LoginAttempt } from '../login_attempt'; +import { ScopedClusterClient } from '../../../../../../src/core/server'; +import { requestFixture } from '../../__fixtures__'; import { mockAuthenticationProviderOptions } from './base.mock'; import { BasicAuthenticationProvider, BasicCredentials } from './basic'; @@ -15,19 +15,53 @@ function generateAuthorizationHeader(username: string, password: string) { headers: { authorization }, } = BasicCredentials.decorateRequest(requestFixture(), username, password); - return authorization; + return authorization as string; } describe('BasicAuthenticationProvider', () => { - describe('`authenticate` method', () => { - let provider: BasicAuthenticationProvider; - let callWithRequest: sinon.SinonStub; - beforeEach(() => { - const providerOptions = mockAuthenticationProviderOptions(); - callWithRequest = providerOptions.client.callWithRequest; - provider = new BasicAuthenticationProvider(providerOptions); + let provider: BasicAuthenticationProvider; + let mockOptions: ReturnType; + beforeEach(() => { + mockOptions = mockAuthenticationProviderOptions(); + provider = new BasicAuthenticationProvider(mockOptions); + }); + + describe('`login` method', () => { + it('does not handle request if login attempt is not correct', async () => { + const request = requestFixture(); + + expect((await provider.login(request, undefined as any)).notHandled()).toBe(true); + expect((await provider.login(request, {} as any)).notHandled()).toBe(true); + expect((await provider.login(request, { username: 'user' } as any)).notHandled()).toBe(true); + expect((await provider.login(request, { password: 'pass' } as any)).notHandled()).toBe(true); + + sinon.assert.notCalled(mockOptions.client.asScoped); }); + it('succeeds with valid login attempt, creates session and authHeaders', async () => { + const user = { username: 'user' }; + const authorization = generateAuthorizationHeader('user', 'password'); + const request = requestFixture(); + + const scopedClusterClient = sinon.createStubInstance(ScopedClusterClient); + mockOptions.client.asScoped + .withArgs(sinon.match({ headers: { authorization } })) + .returns(scopedClusterClient); + scopedClusterClient.callAsCurrentUser.withArgs('shield.authenticate').resolves(user); + + const authenticationResult = await provider.login(request, { + username: 'user', + password: 'password', + }); + + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); + expect(authenticationResult.state).toEqual({ authorization }); + expect(authenticationResult.authHeaders).toEqual({ authorization }); + }); + }); + + describe('`authenticate` method', () => { it('does not redirect AJAX requests that can not be authenticated to the login page.', async () => { // Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and // avoid triggering of redirect logic. @@ -41,16 +75,13 @@ describe('BasicAuthenticationProvider', () => { it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { const authenticationResult = await provider.authenticate( - requestFixture({ - path: '/some-path # that needs to be encoded', - basePath: '/s/foo', - }), + requestFixture({ path: '/s/foo/some-path # that needs to be encoded' }), null ); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe( - '/base-path/login?next=%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' + '/base-path/login?next=%2Fbase-path%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' ); }); @@ -59,48 +90,28 @@ describe('BasicAuthenticationProvider', () => { expect(authenticationResult.notHandled()).toBe(true); }); - it('succeeds with valid login attempt and stores in session', async () => { - const user = { username: 'user' }; - const authorization = generateAuthorizationHeader('user', 'password'); - const request = requestFixture(); - const loginAttempt = new LoginAttempt(); - loginAttempt.setCredentials('user', 'password'); - (request.loginAttempt as sinon.SinonStub).returns(loginAttempt); - - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - - const authenticationResult = await provider.authenticate(request); - - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); - expect(authenticationResult.state).toEqual({ authorization }); - sinon.assert.calledOnce(callWithRequest); - }); - it('succeeds if only `authorization` header is available.', async () => { - const request = BasicCredentials.decorateRequest(requestFixture(), 'user', 'password'); + const request = requestFixture({ + headers: { authorization: generateAuthorizationHeader('user', 'password') }, + }); const user = { username: 'user' }; - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); + const scopedClusterClient = sinon.createStubInstance(ScopedClusterClient); + mockOptions.client.asScoped + .withArgs(sinon.match({ headers: request.headers })) + .returns(scopedClusterClient); + scopedClusterClient.callAsCurrentUser.withArgs('shield.authenticate').resolves(user); const authenticationResult = await provider.authenticate(request); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); - sinon.assert.calledOnce(callWithRequest); - }); - - it('does not return session state for header-based auth', async () => { - const request = BasicCredentials.decorateRequest(requestFixture(), 'user', 'password'); - const user = { username: 'user' }; - - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); - const authenticationResult = await provider.authenticate(request); + // Session state and authHeaders aren't returned for header-based auth. + expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); - expect(authenticationResult.state).not.toEqual({ - authorization: request.headers.authorization, - }); + sinon.assert.calledOnce(scopedClusterClient.callAsCurrentUser); }); it('succeeds if only state is available.', async () => { @@ -108,16 +119,20 @@ describe('BasicAuthenticationProvider', () => { const user = { username: 'user' }; const authorization = generateAuthorizationHeader('user', 'password'); - callWithRequest - .withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate') - .resolves(user); + const scopedClusterClient = sinon.createStubInstance(ScopedClusterClient); + mockOptions.client.asScoped + .withArgs(sinon.match({ headers: { authorization } })) + .returns(scopedClusterClient); + scopedClusterClient.callAsCurrentUser.withArgs('shield.authenticate').resolves(user); const authenticationResult = await provider.authenticate(request, { authorization }); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); expect(authenticationResult.state).toBeUndefined(); - sinon.assert.calledOnce(callWithRequest); + expect(authenticationResult.authHeaders).toEqual({ authorization }); + + sinon.assert.calledOnce(scopedClusterClient.callAsCurrentUser); }); it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => { @@ -126,7 +141,7 @@ describe('BasicAuthenticationProvider', () => { const authenticationResult = await provider.authenticate(request, { authorization }); - sinon.assert.notCalled(callWithRequest); + sinon.assert.notCalled(mockOptions.client.asScoped); expect(request.headers.authorization).toBe('Bearer ***'); expect(authenticationResult.notHandled()).toBe(true); }); @@ -135,9 +150,14 @@ describe('BasicAuthenticationProvider', () => { const request = requestFixture(); const authorization = generateAuthorizationHeader('user', 'password'); + const scopedClusterClient = sinon.createStubInstance(ScopedClusterClient); + mockOptions.client.asScoped + .withArgs(sinon.match({ headers: { authorization } })) + .returns(scopedClusterClient); + const authenticationError = new Error('Forbidden'); - callWithRequest - .withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate') + scopedClusterClient.callAsCurrentUser + .withArgs('shield.authenticate') .rejects(authenticationError); const authenticationResult = await provider.authenticate(request, { authorization }); @@ -146,35 +166,38 @@ describe('BasicAuthenticationProvider', () => { expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.error).toBe(authenticationError); - sinon.assert.calledOnce(callWithRequest); + sinon.assert.calledOnce(scopedClusterClient.callAsCurrentUser); }); it('authenticates only via `authorization` header even if state is available.', async () => { - const request = BasicCredentials.decorateRequest(requestFixture(), 'user', 'password'); + const request = requestFixture({ + headers: { authorization: generateAuthorizationHeader('user', 'password') }, + }); const user = { username: 'user' }; - const authorization = generateAuthorizationHeader('user1', 'password2'); - // GetUser will be called with request's `authorization` header. - callWithRequest.withArgs(request, 'shield.authenticate').resolves(user); + const scopedClusterClient = sinon.createStubInstance(ScopedClusterClient); + mockOptions.client.asScoped + .withArgs(sinon.match({ headers: request.headers })) + .returns(scopedClusterClient); + scopedClusterClient.callAsCurrentUser.withArgs('shield.authenticate').resolves(user); - const authenticationResult = await provider.authenticate(request, { authorization }); + const authorizationInState = generateAuthorizationHeader('user1', 'password2'); + const authenticationResult = await provider.authenticate(request, { + authorization: authorizationInState, + }); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); - expect(authenticationResult.state).not.toEqual({ - authorization: request.headers.authorization, - }); - sinon.assert.calledOnce(callWithRequest); + expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); + + sinon.assert.calledOnce(scopedClusterClient.callAsCurrentUser); }); }); describe('`logout` method', () => { - let provider: BasicAuthenticationProvider; - beforeEach(() => { - provider = new BasicAuthenticationProvider(mockAuthenticationProviderOptions()); - }); - it('always redirects to the login page.', async () => { const request = requestFixture(); const deauthenticateResult = await provider.logout(request); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts similarity index 73% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.ts rename to x-pack/plugins/security/server/authentication/providers/basic.ts index cca60ff8acb102b..b5d0e12638b1717 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -6,7 +6,7 @@ /* eslint-disable max-classes-per-file */ -import { KibanaRequest } from '../../../../../../../../src/core/server'; +import { KibanaRequest } from '../../../../../../src/core/server'; import { canRedirectRequest } from '../can_redirect_request'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -82,27 +82,25 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { attempt: ProviderLoginAttempt, state?: ProviderState | null ) { - this.options.log.debug('Trying to perform a login.'); + this.logger.debug('Trying to perform a login.'); if (!attempt || !attempt.username || !attempt.password) { - this.options.log.debug('Username and/or password not provided.'); + this.logger.debug('Username and/or password not provided.'); return AuthenticationResult.notHandled(); } try { - const authorization = `Basic ${Buffer.from( - `${attempt.username}:${attempt.password}` - ).toString('base64')}`; - - const user = await this.options.client.callWithRequest( - { headers: { ...request.headers, authorization } }, - 'shield.authenticate' - ); + const authHeaders = { + authorization: `Basic ${Buffer.from(`${attempt.username}:${attempt.password}`).toString( + 'base64' + )}`, + }; + const user = await this.getUser(request, authHeaders); - this.options.log.debug('Login has been successfully performed.'); - return AuthenticationResult.succeeded(user, { authorization }, { authorization }); + this.logger.debug('Login has been successfully performed.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: authHeaders }); } catch (err) { - this.options.log.debug(`Failed to perform a login: ${err.message}`); + this.logger.debug(`Failed to perform a login: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -113,7 +111,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { * @param [state] Optional state object associated with the provider. */ public async authenticate(request: KibanaRequest, state?: ProviderState | null) { - this.options.log.debug(`Trying to authenticate user request to ${request.url.path}.`); + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); // try header-based auth const { @@ -129,9 +127,11 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { authenticationResult = await this.authenticateViaState(request, state); } else if (authenticationResult.notHandled() && canRedirectRequest(request)) { // If we couldn't handle authentication let's redirect user to the login page. - const nextURL = encodeURIComponent(`${this.options.basePath}${request.url.path}`); + const nextURL = encodeURIComponent( + `${this.options.basePath.get(request)}${request.url.path}` + ); authenticationResult = AuthenticationResult.redirectTo( - `${this.options.basePath}/login?next=${nextURL}` + `${this.options.basePath.get(request)}/login?next=${nextURL}` ); } @@ -146,7 +146,9 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { // Query string may contain the path where logout has been called or // logout reason that login page may need to know. const queryString = request.url.search || `?msg=LOGGED_OUT`; - return DeauthenticationResult.redirectTo(`${this.options.basePath}/login${queryString}`); + return DeauthenticationResult.redirectTo( + `${this.options.basePath.get(request)}/login${queryString}` + ); } /** @@ -155,17 +157,17 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. */ private async authenticateViaHeader(request: KibanaRequest) { - this.options.log.debug('Trying to authenticate via header.'); + this.logger.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; if (!authorization || typeof authorization !== 'string') { - this.options.log.debug('Authorization header is not presented.'); + this.logger.debug('Authorization header is not presented.'); return { authenticationResult: AuthenticationResult.notHandled() }; } const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'basic') { - this.options.log.debug(`Unsupported authentication schema: ${authenticationSchema}`); + this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true, @@ -173,12 +175,12 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { } try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const user = await this.getUser(request); - this.options.log.debug('Request has been authenticated via header.'); + this.logger.debug('Request has been authenticated via header.'); return { authenticationResult: AuthenticationResult.succeeded(user) }; } catch (err) { - this.options.log.debug(`Failed to authenticate request via header: ${err.message}`); + this.logger.debug(`Failed to authenticate request via header: ${err.message}`); return { authenticationResult: AuthenticationResult.failed(err) }; } } @@ -190,23 +192,21 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaState(request: KibanaRequest, { authorization }: ProviderState) { - this.options.log.debug('Trying to authenticate via state.'); + this.logger.debug('Trying to authenticate via state.'); if (!authorization) { - this.options.log.debug('Access token is not found in state.'); + this.logger.debug('Access token is not found in state.'); return AuthenticationResult.notHandled(); } try { - const user = await this.options.client.callWithRequest( - { headers: { ...request.headers, authorization } }, - 'shield.authenticate' - ); + const authHeaders = { authorization }; + const user = await this.getUser(request, authHeaders); - this.options.log.debug('Request has been authenticated via state.'); - return AuthenticationResult.succeeded(user, { authorization }); + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.options.log.debug(`Failed to authenticate request via state: ${err.message}`); + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/index.ts rename to x-pack/plugins/security/server/authentication/providers/index.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.test.ts rename to x-pack/plugins/security/server/authentication/providers/kerberos.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts similarity index 70% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.ts rename to x-pack/plugins/security/server/authentication/providers/kerberos.ts index 8feb24af699f98f..73192b611284a2b 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -6,7 +6,7 @@ import Boom from 'boom'; import { get } from 'lodash'; -import { KibanaRequest } from '../../../../../../../../src/core/server'; +import { KibanaRequest } from '../../../../../../src/core/server'; import { getErrorStatusCode } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -41,14 +41,14 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param [state] Optional state object associated with the provider. */ public async authenticate(request: KibanaRequest, state?: ProviderState | null) { - this.options.log.debug(`Trying to authenticate user request to ${request.url.path}.`); + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); const authenticationScheme = getRequestAuthenticationScheme(request); if ( authenticationScheme && (authenticationScheme !== 'negotiate' && authenticationScheme !== 'bearer') ) { - this.options.log.debug(`Unsupported authentication scheme: ${authenticationScheme}`); + this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); return AuthenticationResult.notHandled(); } @@ -84,17 +84,17 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ public async logout(request: KibanaRequest, state?: ProviderState | null) { - this.options.log.debug(`Trying to log user out via ${request.url.path}.`); + this.logger.debug(`Trying to log user out via ${request.url.path}.`); if (!state) { - this.options.log.debug('There is no access token invalidate.'); + this.logger.debug('There is no access token invalidate.'); return DeauthenticationResult.notHandled(); } try { await this.options.tokens.invalidate(state); } catch (err) { - this.options.log.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`); + this.logger.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`); return DeauthenticationResult.failed(err); } @@ -107,41 +107,35 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. */ private async authenticateWithNegotiateScheme(request: KibanaRequest) { - this.options.log.debug( - 'Trying to authenticate request using "Negotiate" authentication scheme.' - ); + this.logger.debug('Trying to authenticate request using "Negotiate" authentication scheme.'); const [, kerberosTicket] = (request.headers.authorization as string).split(/\s+/); // First attempt to exchange SPNEGO token for an access token. let tokens: { access_token: string; refresh_token: string }; try { - tokens = await this.options.client.callWithInternalUser('shield.getAccessToken', { + tokens = await this.options.client.callAsInternalUser('shield.getAccessToken', { body: { grant_type: '_kerberos', kerberos_ticket: kerberosTicket }, }); } catch (err) { - this.options.log.debug(`Failed to exchange SPNEGO token for an access token: ${err.message}`); + this.logger.debug(`Failed to exchange SPNEGO token for an access token: ${err.message}`); return AuthenticationResult.failed(err); } - this.options.log.debug('Get token API request to Elasticsearch successful'); + this.logger.debug('Get token API request to Elasticsearch successful'); try { // Then attempt to query for the user details using the new token - const authorization = `Bearer ${tokens.access_token}`; - const user = await this.options.client.callWithRequest( - { headers: { ...request.headers, authorization } }, - 'shield.authenticate' - ); + const authHeaders = { authorization: `Bearer ${tokens.access_token}` }; + const user = await this.getUser(request, authHeaders); - this.options.log.debug('User has been authenticated with new access token'); - return AuthenticationResult.succeeded( - user, - { authorization }, - { accessToken: tokens.access_token, refreshToken: tokens.refresh_token } - ); + this.logger.debug('User has been authenticated with new access token'); + return AuthenticationResult.succeeded(user, { + authHeaders, + state: { accessToken: tokens.access_token, refreshToken: tokens.refresh_token }, + }); } catch (err) { - this.options.log.debug(`Failed to authenticate request via access token: ${err.message}`); + this.logger.debug(`Failed to authenticate request via access token: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -151,17 +145,15 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. */ private async authenticateWithBearerScheme(request: KibanaRequest) { - this.options.log.debug('Trying to authenticate request using "Bearer" authentication scheme.'); + this.logger.debug('Trying to authenticate request using "Bearer" authentication scheme.'); try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const user = await this.getUser(request); - this.options.log.debug( - 'Request has been authenticated using "Bearer" authentication scheme.' - ); + this.logger.debug('Request has been authenticated using "Bearer" authentication scheme.'); return AuthenticationResult.succeeded(user); } catch (err) { - this.options.log.debug( + this.logger.debug( `Failed to authenticate request using "Bearer" authentication scheme: ${err.message}` ); return AuthenticationResult.failed(err); @@ -175,24 +167,21 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { - this.options.log.debug('Trying to authenticate via state.'); + this.logger.debug('Trying to authenticate via state.'); if (!accessToken) { - this.options.log.debug('Access token is not found in state.'); + this.logger.debug('Access token is not found in state.'); return AuthenticationResult.notHandled(); } try { - const authorization = `Bearer ${accessToken}`; - const user = await this.options.client.callWithRequest( - { headers: { ...request.headers, authorization } }, - 'shield.authenticate' - ); + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.options.log.debug('Request has been authenticated via state.'); - return AuthenticationResult.succeeded(user, { authorization }); + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.options.log.debug(`Failed to authenticate request via state: ${err.message}`); + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -205,7 +194,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaRefreshToken(request: KibanaRequest, state: ProviderState) { - this.options.log.debug('Trying to refresh access token.'); + this.logger.debug('Trying to refresh access token.'); let refreshedTokenPair: TokenPair | null; try { @@ -216,23 +205,20 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { // If refresh token is no longer valid, then we should clear session and renegotiate using SPNEGO. if (refreshedTokenPair === null) { - this.options.log.debug( + this.logger.debug( 'Both access and refresh tokens are expired. Re-initiating SPNEGO handshake.' ); return this.authenticateViaSPNEGO(request, state); } try { - const authorization = `Bearer ${refreshedTokenPair.accessToken}`; - const user = await this.options.client.callWithRequest( - { headers: { ...request.headers, authorization } }, - 'shield.authenticate' - ); + const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.options.log.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, { authorization }, refreshedTokenPair); + this.logger.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); } catch (err) { - this.options.log.debug( + this.logger.debug( `Failed to authenticate user using newly refreshed access token: ${err.message}` ); return AuthenticationResult.failed(err); @@ -245,13 +231,13 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * @param [state] Optional state object associated with the provider. */ private async authenticateViaSPNEGO(request: KibanaRequest, state?: ProviderState | null) { - this.options.log.debug('Trying to authenticate request via SPNEGO.'); + this.logger.debug('Trying to authenticate request via SPNEGO.'); // Try to authenticate current request with Elasticsearch to see whether it supports SPNEGO. let authenticationError: Error; try { - await this.options.client.callWithRequest(request, 'shield.authenticate'); - this.options.log.debug('Request was not supposed to be authenticated, ignoring result.'); + await this.getUser(request); + this.logger.debug('Request was not supposed to be authenticated, ignoring result.'); return AuthenticationResult.notHandled(); } catch (err) { // Fail immediately if we get unexpected error (e.g. ES isn't available). We should not touch @@ -268,15 +254,11 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { ); if (challenges.some(challenge => challenge.toLowerCase() === 'negotiate')) { - this.options.log.debug( - `SPNEGO is supported by the backend, challenges are: [${challenges}].` - ); + this.logger.debug(`SPNEGO is supported by the backend, challenges are: [${challenges}].`); return AuthenticationResult.failed(Boom.unauthorized(), ['Negotiate']); } - this.options.log.debug( - `SPNEGO is not supported by the backend, challenges are: [${challenges}].` - ); + this.logger.debug(`SPNEGO is not supported by the backend, challenges are: [${challenges}].`); // If we failed to do SPNEGO and have a session with expired token that belongs to Kerberos // authentication provider then it means Elasticsearch isn't configured to use Kerberos anymore. diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.test.ts rename to x-pack/plugins/security/server/authentication/providers/oidc.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts similarity index 79% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.ts rename to x-pack/plugins/security/server/authentication/providers/oidc.ts index 16bfb24dd8cbbfc..f7695a060bbdf96 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -7,7 +7,7 @@ import Boom from 'boom'; import type from 'type-detect'; import { canRedirectRequest } from '../'; -import { KibanaRequest } from '../../../../../../../../src/core/server'; +import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { Tokens, TokenPair } from '../tokens'; @@ -84,7 +84,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { attempt: ProviderLoginAttempt, state?: ProviderState | null ) { - this.options.log.debug('Trying to perform a login.'); + this.logger.debug('Trying to perform a login.'); // This might be the OpenID Connect Provider redirecting the user to `redirect_uri` after authentication or // a third party initiating an authentication @@ -97,7 +97,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param [state] Optional state object associated with the provider. */ public async authenticate(request: KibanaRequest, state?: ProviderState | null) { - this.options.log.debug(`Trying to authenticate user request to ${request.url.path}.`); + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. let { @@ -148,11 +148,11 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { { iss, loginHint, code }: ProviderLoginAttempt, sessionState?: ProviderState | null ) { - this.options.log.debug('Trying to authenticate via OpenID Connect response query.'); + this.logger.debug('Trying to authenticate via OpenID Connect response query.'); // First check to see if this is a Third Party initiated authentication. if (iss) { - this.options.log.debug('Authentication has been initiated by a Third Party.'); + this.logger.debug('Authentication has been initiated by a Third Party.'); // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) @@ -161,7 +161,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } if (!code) { - this.options.log.debug('OpenID Connect Authentication response is not found.'); + this.logger.debug('OpenID Connect Authentication response is not found.'); return AuthenticationResult.notHandled(); } @@ -173,7 +173,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { if (!stateNonce || !stateOIDCState || !stateRedirectURL) { const message = 'Response session state does not have corresponding state or nonce parameters or redirect URL.'; - this.options.log.debug(message); + this.logger.debug(message); return AuthenticationResult.failed(Boom.badRequest(message)); } @@ -184,7 +184,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { const { access_token: accessToken, refresh_token: refreshToken, - } = await this.options.client.callWithInternalUser('shield.oidcAuthenticate', { + } = await this.options.client.callAsInternalUser('shield.oidcAuthenticate', { body: { state: stateOIDCState, nonce: stateNonce, @@ -195,14 +195,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { }, }); - this.options.log.debug('Request has been authenticated via OpenID Connect.'); + this.logger.debug('Request has been authenticated via OpenID Connect.'); return AuthenticationResult.redirectTo(stateRedirectURL, { accessToken, refreshToken, }); } catch (err) { - this.options.log.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); + this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -219,13 +219,11 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { params: { realm: string } | { iss: string; login_hint?: string }, sessionState?: ProviderState | null ) { - this.options.log.debug('Trying to initiate OpenID Connect authentication.'); + this.logger.debug('Trying to initiate OpenID Connect authentication.'); // If client can't handle redirect response, we shouldn't initiate OpenID Connect authentication. if (!canRedirectRequest(request)) { - this.options.log.debug( - 'OpenID Connect authentication can not be initiated by AJAX requests.' - ); + this.logger.debug('OpenID Connect authentication can not be initiated by AJAX requests.'); return AuthenticationResult.notHandled(); } @@ -241,16 +239,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { : params; // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/oidc/prepare`. - const { state, nonce, redirect } = await this.options.client.callWithInternalUser( + const { state, nonce, redirect } = await this.options.client.callAsInternalUser( 'shield.oidcPrepare', - { - body: oidcPrepareParams, - } + { body: oidcPrepareParams } ); - this.options.log.debug('Redirecting to OpenID Connect Provider with authentication request.'); + this.logger.debug('Redirecting to OpenID Connect Provider with authentication request.'); // If this is a third party initiated login, redirect to the base path - const redirectAfterLogin = `${this.options.basePath}${ + const redirectAfterLogin = `${this.options.basePath.get(request)}${ 'iss' in params ? '/' : request.url.path }`; return AuthenticationResult.redirectTo( @@ -259,7 +255,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { { state, nonce, nextURL: redirectAfterLogin } ); } catch (err) { - this.options.log.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); + this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -270,11 +266,11 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. */ private async authenticateViaHeader(request: KibanaRequest) { - this.options.log.debug('Trying to authenticate via header.'); + this.logger.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; if (!authorization || typeof authorization !== 'string') { - this.options.log.debug('Authorization header is not presented.'); + this.logger.debug('Authorization header is not presented.'); return { authenticationResult: AuthenticationResult.notHandled(), }; @@ -282,7 +278,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'bearer') { - this.options.log.debug(`Unsupported authentication schema: ${authenticationSchema}`); + this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true, @@ -290,14 +286,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const user = await this.getUser(request); - this.options.log.debug('Request has been authenticated via header.'); + this.logger.debug('Request has been authenticated via header.'); return { authenticationResult: AuthenticationResult.succeeded(user), }; } catch (err) { - this.options.log.debug(`Failed to authenticate request via header: ${err.message}`); + this.logger.debug(`Failed to authenticate request via header: ${err.message}`); return { authenticationResult: AuthenticationResult.failed(err), }; @@ -311,24 +307,21 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { - this.options.log.debug('Trying to authenticate via state.'); + this.logger.debug('Trying to authenticate via state.'); if (!accessToken) { - this.options.log.debug('Elasticsearch access token is not found in state.'); + this.logger.debug('Elasticsearch access token is not found in state.'); return AuthenticationResult.notHandled(); } try { - const authorization = `Bearer ${accessToken}`; - const user = await this.options.client.callWithRequest( - { headers: { ...request.headers, authorization } }, - 'shield.authenticate' - ); + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.options.log.debug('Request has been authenticated via state.'); - return AuthenticationResult.succeeded(user, { authorization }); + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.options.log.debug(`Failed to authenticate request via state: ${err.message}`); + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -344,10 +337,10 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { request: KibanaRequest, { refreshToken }: ProviderState ) { - this.options.log.debug('Trying to refresh elasticsearch access token.'); + this.logger.debug('Trying to refresh elasticsearch access token.'); if (!refreshToken) { - this.options.log.debug('Refresh token is not found in state.'); + this.logger.debug('Refresh token is not found in state.'); return AuthenticationResult.notHandled(); } @@ -366,7 +359,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // supported. if (refreshedTokenPair === null) { if (canRedirectRequest(request)) { - this.options.log.debug( + this.logger.debug( 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' ); return this.initiateOIDCAuthentication(request, { realm: this.realm }); @@ -378,16 +371,13 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authorization = `Bearer ${refreshedTokenPair.accessToken}`; - const user = await this.options.client.callWithRequest( - { headers: { ...request.headers, authorization } }, - 'shield.authenticate' - ); + const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.options.log.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, { authorization }, refreshedTokenPair); + this.logger.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); } catch (err) { - this.options.log.debug(`Failed to refresh elasticsearch access token: ${err.message}`); + this.logger.debug(`Failed to refresh elasticsearch access token: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -399,10 +389,10 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ public async logout(request: KibanaRequest, state: ProviderState) { - this.options.log.debug(`Trying to log user out via ${request.url.path}.`); + this.logger.debug(`Trying to log user out via ${request.url.path}.`); if (!state || !state.accessToken) { - this.options.log.debug('There is no elasticsearch access token to invalidate.'); + this.logger.debug('There is no elasticsearch access token to invalidate.'); return DeauthenticationResult.notHandled(); } @@ -415,26 +405,24 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { }; // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/oidc/logout`. - const { redirect } = await this.options.client.callWithInternalUser( + const { redirect } = await this.options.client.callAsInternalUser( 'shield.oidcLogout', logoutBody ); - this.options.log.debug('User session has been successfully invalidated.'); + this.logger.debug('User session has been successfully invalidated.'); // Having non-null `redirect` field within logout response means that the OpenID Connect realm configuration // supports RP initiated Single Logout and we should redirect user to the specified location in the OpenID Connect // Provider to properly complete logout. if (redirect != null) { - this.options.log.debug( - 'Redirecting user to the OpenID Connect Provider to complete logout.' - ); + this.logger.debug('Redirecting user to the OpenID Connect Provider to complete logout.'); return DeauthenticationResult.redirectTo(redirect); } - return DeauthenticationResult.redirectTo(`${this.options.basePath}/logged_out`); + return DeauthenticationResult.redirectTo(`${this.options.basePath.get(request)}/logged_out`); } catch (err) { - this.options.log.debug(`Failed to deauthenticate user: ${err.message}`); + this.logger.debug(`Failed to deauthenticate user: ${err.message}`); return DeauthenticationResult.failed(err); } } diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.test.ts rename to x-pack/plugins/security/server/authentication/providers/saml.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts similarity index 78% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.ts rename to x-pack/plugins/security/server/authentication/providers/saml.ts index df59d7c8ed24f3c..c0c7fed35a9952d 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -6,8 +6,8 @@ import Boom from 'boom'; import { canRedirectRequest } from '..'; -import { KibanaRequest } from '../../../../../../../../src/core/server'; -import { AuthenticatedUser } from '../../../../common/model'; +import { KibanaRequest } from '../../../../../../src/core/server'; +import { AuthenticatedUser } from '../../../common/model'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; @@ -76,7 +76,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { { samlResponse }: ProviderLoginAttempt, state?: ProviderState | null ) { - this.options.log.debug('Trying to perform a login.'); + this.logger.debug('Trying to perform a login.'); const authenticationResult = state ? await this.authenticateViaState(request, state) @@ -84,7 +84,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // Let's check if user is redirected to Kibana from IdP with valid SAMLResponse. if (authenticationResult.notHandled()) { - return await this.loginWithSAMLResponse(samlResponse, state); + return await this.loginWithSAMLResponse(request, samlResponse, state); } if (authenticationResult.succeeded()) { @@ -100,9 +100,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } if (authenticationResult.succeeded() || authenticationResult.redirected()) { - this.options.log.debug('Login has been successfully performed.'); + this.logger.debug('Login has been successfully performed.'); } else { - this.options.log.debug( + this.logger.debug( `Failed to perform a login: ${authenticationResult.error && authenticationResult.error.message}` ); @@ -117,7 +117,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param [state] Optional state object associated with the provider. */ public async authenticate(request: KibanaRequest, state?: ProviderState | null) { - this.options.log.debug(`Trying to authenticate user request to ${request.url.path}.`); + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. let { @@ -152,10 +152,10 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ public async logout(request: KibanaRequest, state?: ProviderState) { - this.options.log.debug(`Trying to log user out via ${request.url.path}.`); + this.logger.debug(`Trying to log user out via ${request.url.path}.`); if ((!state || !state.accessToken) && !isSAMLRequestQuery(request.query)) { - this.options.log.debug('There is neither access token nor SAML session to invalidate.'); + this.logger.debug('There is neither access token nor SAML session to invalidate.'); return DeauthenticationResult.notHandled(); } @@ -168,13 +168,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // supports SAML Single Logout and we should redirect user to the specified // location to properly complete logout. if (redirect != null) { - this.options.log.debug('Redirecting user to Identity Provider to complete logout.'); + this.logger.debug('Redirecting user to Identity Provider to complete logout.'); return DeauthenticationResult.redirectTo(redirect); } return DeauthenticationResult.redirectTo('/logged_out'); } catch (err) { - this.options.log.debug(`Failed to deauthenticate user: ${err.message}`); + this.logger.debug(`Failed to deauthenticate user: ${err.message}`); return DeauthenticationResult.failed(err); } } @@ -185,17 +185,17 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. */ private async authenticateViaHeader(request: KibanaRequest) { - this.options.log.debug('Trying to authenticate via header.'); + this.logger.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; if (!authorization || typeof authorization !== 'string') { - this.options.log.debug('Authorization header is not presented.'); + this.logger.debug('Authorization header is not presented.'); return { authenticationResult: AuthenticationResult.notHandled() }; } const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'bearer') { - this.options.log.debug(`Unsupported authentication schema: ${authenticationSchema}`); + this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true, @@ -203,12 +203,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const user = await this.getUser(request); - this.options.log.debug('Request has been authenticated via header.'); + this.logger.debug('Request has been authenticated via header.'); return { authenticationResult: AuthenticationResult.succeeded(user) }; } catch (err) { - this.options.log.debug(`Failed to authenticate request via header: ${err.message}`); + this.logger.debug(`Failed to authenticate request via header: ${err.message}`); return { authenticationResult: AuthenticationResult.failed(err) }; } } @@ -225,11 +225,16 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * When login succeeds access token is stored in the state and user is redirected to the URL * that was requested before SAML handshake or to default Kibana location in case of IdP * initiated login. + * @param request Request instance. * @param samlResponse SAMLResponse payload string. * @param [state] Optional state object associated with the provider. */ - private async loginWithSAMLResponse(samlResponse: string, state?: ProviderState | null) { - this.options.log.debug('Trying to log in with SAML response payload.'); + private async loginWithSAMLResponse( + request: KibanaRequest, + samlResponse: string, + state?: ProviderState | null + ) { + this.logger.debug('Trying to log in with SAML response payload.'); // If we have a `SAMLResponse` and state, but state doesn't contain all the necessary information, // then something unexpected happened and we should fail. @@ -239,12 +244,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { }; if (state && (!stateRequestId || !stateRedirectURL)) { const message = 'SAML response state does not have corresponding request id or redirect URL.'; - this.options.log.debug(message); + this.logger.debug(message); return AuthenticationResult.failed(Boom.badRequest(message)); } // When we don't have state and hence request id we assume that SAMLResponse came from the IdP initiated login. - this.options.log.debug( + this.logger.debug( stateRequestId ? 'Login has been previously initiated by Kibana.' : 'Login has been initiated by Identity Provider.' @@ -256,20 +261,20 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { const { access_token: accessToken, refresh_token: refreshToken, - } = await this.options.client.callWithInternalUser('shield.samlAuthenticate', { + } = await this.options.client.callAsInternalUser('shield.samlAuthenticate', { body: { ids: stateRequestId ? [stateRequestId] : [], content: samlResponse, }, }); - this.options.log.debug('Login has been performed with SAML response.'); - return AuthenticationResult.redirectTo(stateRedirectURL || `${this.options.basePath}/`, { - accessToken, - refreshToken, - }); + this.logger.debug('Login has been performed with SAML response.'); + return AuthenticationResult.redirectTo( + stateRedirectURL || `${this.options.basePath.get(request)}/`, + { accessToken, refreshToken } + ); } catch (err) { - this.options.log.debug(`Failed to log in with SAML response: ${err.message}`); + this.logger.debug(`Failed to log in with SAML response: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -293,12 +298,10 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { existingState: ProviderState, user: AuthenticatedUser ) { - this.options.log.debug( - 'Trying to log in with SAML response payload and existing valid session.' - ); + this.logger.debug('Trying to log in with SAML response payload and existing valid session.'); // First let's try to authenticate via SAML Response payload. - const payloadAuthenticationResult = await this.loginWithSAMLResponse(samlResponse); + const payloadAuthenticationResult = await this.loginWithSAMLResponse(request, samlResponse); if (payloadAuthenticationResult.failed()) { return payloadAuthenticationResult; } @@ -328,13 +331,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // Now let's invalidate tokens from the existing session. try { - this.options.log.debug('Perform IdP initiated local logout.'); + this.logger.debug('Perform IdP initiated local logout.'); await this.options.tokens.invalidate({ accessToken: existingState.accessToken!, refreshToken: existingState.refreshToken!, }); } catch (err) { - this.options.log.debug(`Failed to perform IdP initiated local logout: ${err.message}`); + this.logger.debug(`Failed to perform IdP initiated local logout: ${err.message}`); return AuthenticationResult.failed(err); } @@ -342,18 +345,16 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { newUserAuthenticationResult.user.username !== user.username || newUserAuthenticationResult.user.authentication_realm.name !== user.authentication_realm.name ) { - this.options.log.debug( + this.logger.debug( 'Login initiated by Identity Provider is for a different user than currently authenticated.' ); return AuthenticationResult.redirectTo( - `${this.options.basePath}/overwritten_session`, + `${this.options.basePath.get(request)}/overwritten_session`, newState ); } - this.options.log.debug( - 'Login initiated by Identity Provider is for currently authenticated user.' - ); + this.logger.debug('Login initiated by Identity Provider is for currently authenticated user.'); return payloadAuthenticationResult; } @@ -364,24 +365,21 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { - this.options.log.debug('Trying to authenticate via state.'); + this.logger.debug('Trying to authenticate via state.'); if (!accessToken) { - this.options.log.debug('Access token is not found in state.'); + this.logger.debug('Access token is not found in state.'); return AuthenticationResult.notHandled(); } try { - const authorization = `Bearer ${accessToken}`; - const user = await this.options.client.callWithRequest( - { headers: { ...request.headers, authorization } }, - 'shield.authenticate' - ); + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.options.log.debug('Request has been authenticated via state.'); - return AuthenticationResult.succeeded(user, { authorization }); + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.options.log.debug(`Failed to authenticate request via state: ${err.message}`); + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -397,10 +395,10 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { request: KibanaRequest, { refreshToken }: ProviderState ) { - this.options.log.debug('Trying to refresh access token.'); + this.logger.debug('Trying to refresh access token.'); if (!refreshToken) { - this.options.log.debug('Refresh token is not found in state.'); + this.logger.debug('Refresh token is not found in state.'); return AuthenticationResult.notHandled(); } @@ -418,7 +416,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // to do the same on Kibana side and `401` would force user to logout and do full SLO if it's supported. if (refreshedTokenPair === null) { if (canRedirectRequest(request)) { - this.options.log.debug( + this.logger.debug( 'Both access and refresh tokens are expired. Re-initiating SAML handshake.' ); return this.authenticateViaHandshake(request); @@ -430,16 +428,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authorization = `Bearer ${refreshedTokenPair.accessToken}`; - const user = await this.options.client.callWithRequest( - { headers: { ...request.headers, authorization } }, - 'shield.authenticate' - ); + const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.options.log.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, { authorization }, refreshedTokenPair); + this.logger.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); } catch (err) { - this.options.log.debug( + this.logger.debug( `Failed to authenticate user using newly refreshed access token: ${err.message}` ); return AuthenticationResult.failed(err); @@ -451,30 +446,30 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. */ private async authenticateViaHandshake(request: KibanaRequest) { - this.options.log.debug('Trying to initiate SAML handshake.'); + this.logger.debug('Trying to initiate SAML handshake.'); // If client can't handle redirect response, we shouldn't initiate SAML handshake. if (!canRedirectRequest(request)) { - this.options.log.debug('SAML handshake can not be initiated by AJAX requests.'); + this.logger.debug('SAML handshake can not be initiated by AJAX requests.'); return AuthenticationResult.notHandled(); } try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/prepare`. - const { id: requestId, redirect } = await this.options.client.callWithInternalUser( + const { id: requestId, redirect } = await this.options.client.callAsInternalUser( 'shield.samlPrepare', { body: { realm: this.realm } } ); - this.options.log.debug('Redirecting to Identity Provider with SAML request.'); + this.logger.debug('Redirecting to Identity Provider with SAML request.'); return AuthenticationResult.redirectTo( redirect, // Store request id in the state so that we can reuse it once we receive `SAMLResponse`. - { requestId, nextURL: `${this.options.basePath}${request.url.path}` } + { requestId, nextURL: `${this.options.basePath.get(request)}${request.url.path}` } ); } catch (err) { - this.options.log.debug(`Failed to initiate SAML handshake: ${err.message}`); + this.logger.debug(`Failed to initiate SAML handshake: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -485,15 +480,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param refreshToken Refresh token to invalidate. */ private async performUserInitiatedSingleLogout(accessToken: string, refreshToken: string) { - this.options.log.debug('Single logout has been initiated by the user.'); + this.logger.debug('Single logout has been initiated by the user.'); // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/logout`. - const { redirect } = await this.options.client.callWithInternalUser('shield.samlLogout', { + const { redirect } = await this.options.client.callAsInternalUser('shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); - this.options.log.debug('User session has been successfully invalidated.'); + this.logger.debug('User session has been successfully invalidated.'); return redirect; } @@ -504,11 +499,11 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. */ private async performIdPInitiatedSingleLogout(request: KibanaRequest) { - this.options.log.debug('Single logout has been initiated by the Identity Provider.'); + this.logger.debug('Single logout has been initiated by the Identity Provider.'); // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/invalidate`. - const { redirect } = await this.options.client.callWithInternalUser('shield.samlInvalidate', { + const { redirect } = await this.options.client.callAsInternalUser('shield.samlInvalidate', { // Elasticsearch expects `queryString` without leading `?`, so we should strip it with `slice`. body: { queryString: request.url.search ? request.url.search.slice(1) : '', @@ -516,7 +511,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { }, }); - this.options.log.debug('User session has been successfully invalidated.'); + this.logger.debug('User session has been successfully invalidated.'); return redirect; } diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/token.test.ts rename to x-pack/plugins/security/server/authentication/providers/token.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts similarity index 69% rename from x-pack/legacy/plugins/security/server/lib/authentication/providers/token.ts rename to x-pack/plugins/security/server/authentication/providers/token.ts index 8e325f9a53034ba..24b1da67bade824 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { KibanaRequest } from '../../../../../../../../src/core/server'; +import { KibanaRequest } from '../../../../../../src/core/server'; import { canRedirectRequest } from '..'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -46,10 +46,10 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { credentials: ProviderLoginAttempt, state?: ProviderState | null ) { - this.options.log.debug('Trying to perform a login.'); + this.logger.debug('Trying to perform a login.'); if (!credentials || !credentials.username || !credentials.password) { - this.options.log.debug('Username and/or password not provided.'); + this.logger.debug('Username and/or password not provided.'); return AuthenticationResult.notHandled(); } @@ -58,7 +58,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { const { access_token: accessToken, refresh_token: refreshToken, - } = await this.options.client.callWithInternalUser('shield.getAccessToken', { + } = await this.options.client.callAsInternalUser('shield.getAccessToken', { body: { grant_type: 'password', username: credentials.username, @@ -66,19 +66,19 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { }, }); - this.options.log.debug('Get token API request to Elasticsearch successful'); + this.logger.debug('Get token API request to Elasticsearch successful'); // Then attempt to query for the user details using the new token - const authorization = `Bearer ${accessToken}`; - const user = await this.options.client.callWithRequest( - { headers: { ...request.headers, authorization } }, - 'shield.authenticate' - ); + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.options.log.debug('Login has been successfully performed.'); - return AuthenticationResult.succeeded(user, { authorization }, { accessToken, refreshToken }); + this.logger.debug('Login has been successfully performed.'); + return AuthenticationResult.succeeded(user, { + authHeaders, + state: { accessToken, refreshToken }, + }); } catch (err) { - this.options.log.debug(`Failed to perform a login: ${err.message}`); + this.logger.debug(`Failed to perform a login: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -89,7 +89,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * @param [state] Optional state object associated with the provider. */ public async authenticate(request: KibanaRequest, state?: ProviderState | null) { - this.options.log.debug(`Trying to authenticate user request to ${request.url.path}.`); + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); // if there isn't a payload, try header-based token auth const { @@ -127,24 +127,26 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ public async logout(request: KibanaRequest, state?: ProviderState | null) { - this.options.log.debug(`Trying to log user out via ${request.url.path}.`); + this.logger.debug(`Trying to log user out via ${request.url.path}.`); if (!state) { - this.options.log.debug('There are no access and refresh tokens to invalidate.'); + this.logger.debug('There are no access and refresh tokens to invalidate.'); return DeauthenticationResult.notHandled(); } - this.options.log.debug('Token-based logout has been initiated by the user.'); + this.logger.debug('Token-based logout has been initiated by the user.'); try { await this.options.tokens.invalidate(state); } catch (err) { - this.options.log.debug(`Failed invalidating user's access token: ${err.message}`); + this.logger.debug(`Failed invalidating user's access token: ${err.message}`); return DeauthenticationResult.failed(err); } const queryString = request.url.search || `?msg=LOGGED_OUT`; - return DeauthenticationResult.redirectTo(`${this.options.basePath}/login${queryString}`); + return DeauthenticationResult.redirectTo( + `${this.options.basePath.get(request)}/login${queryString}` + ); } /** @@ -153,30 +155,30 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. */ private async authenticateViaHeader(request: KibanaRequest) { - this.options.log.debug('Trying to authenticate via header.'); + this.logger.debug('Trying to authenticate via header.'); const authorization = request.headers.authorization; if (!authorization || typeof authorization !== 'string') { - this.options.log.debug('Authorization header is not presented.'); + this.logger.debug('Authorization header is not presented.'); return { authenticationResult: AuthenticationResult.notHandled() }; } const authenticationSchema = authorization.split(/\s+/)[0]; if (authenticationSchema.toLowerCase() !== 'bearer') { - this.options.log.debug(`Unsupported authentication schema: ${authenticationSchema}`); + this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true }; } try { - const user = await this.options.client.callWithRequest(request, 'shield.authenticate'); + const user = await this.getUser(request); - this.options.log.debug('Request has been authenticated via header.'); + this.logger.debug('Request has been authenticated via header.'); // We intentionally do not store anything in session state because token // header auth can only be used on a request by request basis. return { authenticationResult: AuthenticationResult.succeeded(user) }; } catch (err) { - this.options.log.debug(`Failed to authenticate request via header: ${err.message}`); + this.logger.debug(`Failed to authenticate request via header: ${err.message}`); return { authenticationResult: AuthenticationResult.failed(err) }; } } @@ -188,19 +190,16 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * @param state State value previously stored by the provider. */ private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { - this.options.log.debug('Trying to authenticate via state.'); + this.logger.debug('Trying to authenticate via state.'); try { - const authorization = `Bearer ${accessToken}`; - const user = await this.options.client.callWithRequest( - { headers: { ...request.headers, authorization } }, - 'shield.authenticate' - ); + const authHeaders = { authorization: `Bearer ${accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.options.log.debug('Request has been authenticated via state.'); - return AuthenticationResult.succeeded(user, { authorization }); + this.logger.debug('Request has been authenticated via state.'); + return AuthenticationResult.succeeded(user, { authHeaders }); } catch (err) { - this.options.log.debug(`Failed to authenticate request via state: ${err.message}`); + this.logger.debug(`Failed to authenticate request via state: ${err.message}`); return AuthenticationResult.failed(err); } } @@ -216,7 +215,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { request: KibanaRequest, { refreshToken }: ProviderState ) { - this.options.log.debug('Trying to refresh access token.'); + this.logger.debug('Trying to refresh access token.'); let refreshedTokenPair: TokenPair | null; try { @@ -229,9 +228,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { // login page to re-authenticate, or fail if redirect isn't possible. if (refreshedTokenPair === null) { if (canRedirectRequest(request)) { - this.options.log.debug( - 'Clearing session since both access and refresh tokens are expired.' - ); + this.logger.debug('Clearing session since both access and refresh tokens are expired.'); // Set state to `null` to let `Authenticator` know that we want to clear current session. return AuthenticationResult.redirectTo(this.getLoginPageURL(request), null); @@ -243,16 +240,13 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { } try { - const authorization = `Bearer ${refreshedTokenPair.accessToken}`; - const user = await this.options.client.callWithRequest( - { headers: { ...request.headers, authorization } }, - 'shield.authenticate' - ); + const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` }; + const user = await this.getUser(request, authHeaders); - this.options.log.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, { authorization }, refreshedTokenPair); + this.logger.debug('Request has been authenticated via refreshed token.'); + return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); } catch (err) { - this.options.log.debug( + this.logger.debug( `Failed to authenticate user using newly refreshed access token: ${err.message}` ); return AuthenticationResult.failed(err); @@ -264,7 +258,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. */ private getLoginPageURL(request: KibanaRequest) { - const nextURL = encodeURIComponent(`${this.options.basePath}${request.url.path}`); - return `${this.options.basePath}/login?next=${nextURL}`; + const nextURL = encodeURIComponent(`${this.options.basePath.get(request)}${request.url.path}`); + return `${this.options.basePath.get(request)}/login?next=${nextURL}`; } } diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/tokens.test.ts b/x-pack/plugins/security/server/authentication/tokens.test.ts similarity index 54% rename from x-pack/legacy/plugins/security/server/lib/authentication/tokens.test.ts rename to x-pack/plugins/security/server/authentication/tokens.test.ts index 9ddb1a80f4956f0..091d0e3f6a521eb 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/tokens.test.ts +++ b/x-pack/plugins/security/server/authentication/tokens.test.ts @@ -7,16 +7,32 @@ import Boom from 'boom'; import { errors } from 'elasticsearch'; import sinon from 'sinon'; +import { ClusterClient } from '../../../../../src/core/server'; import { Tokens } from './tokens'; describe('Tokens', () => { let tokens: Tokens; - let callWithInternalUser: sinon.SinonStub; + let mockClusterClient: sinon.SinonStubbedInstance; beforeEach(() => { - const client = { callWithRequest: sinon.stub(), callWithInternalUser: sinon.stub() }; - const tokensOptions = { client, log: sinon.stub() }; - callWithInternalUser = tokensOptions.client.callWithInternalUser as sinon.SinonStub; + mockClusterClient = { + callAsInternalUser: sinon.stub(), + asScoped: sinon.stub(), + close: sinon.stub(), + }; + + const tokensOptions = { + client: mockClusterClient, + logger: { + debug: sinon.stub(), + error: sinon.stub(), + fatal: sinon.stub(), + info: sinon.stub(), + log: sinon.stub(), + trace: sinon.stub(), + warn: sinon.stub(), + }, + }; tokens = new Tokens(tokensOptions); }); @@ -55,7 +71,7 @@ describe('Tokens', () => { it('throws if API call fails with unknown reason', async () => { const refreshFailureReason = Boom.serverUnavailable('Server is not available'); - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: refreshToken }, }) @@ -66,7 +82,7 @@ describe('Tokens', () => { it('returns `null` if refresh token is not valid', async () => { const refreshFailureReason = Boom.badRequest(); - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: refreshToken }, }) @@ -77,7 +93,7 @@ describe('Tokens', () => { it('returns token pair if refresh API call succeeds', async () => { const tokenPair = { accessToken: 'access-token', refreshToken: 'refresh-token' }; - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: refreshToken }, }) @@ -92,120 +108,154 @@ describe('Tokens', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.deleteAccessToken', { body: { token: tokenPair.accessToken } }) .rejects(failureReason); - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } }) .resolves({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); - sinon.assert.calledTwice(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { token: tokenPair.accessToken }, - }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { refresh_token: tokenPair.refreshToken }, - }); + sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); }); it('throws if call to delete refresh token responds with an error', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } }) .rejects(failureReason); - callWithInternalUser + mockClusterClient.callAsInternalUser .withArgs('shield.deleteAccessToken', { body: { token: tokenPair.accessToken } }) .resolves({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); - sinon.assert.calledTwice(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { token: tokenPair.accessToken }, - }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { refresh_token: tokenPair.refreshToken }, - }); + sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); }); it('invalidates all provided tokens', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 1 }); + mockClusterClient.callAsInternalUser + .withArgs('shield.deleteAccessToken') + .resolves({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledTwice(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { token: tokenPair.accessToken }, - }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { refresh_token: tokenPair.refreshToken }, - }); + sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); }); it('invalidates only access token if only access token is provided', async () => { const tokenPair = { accessToken: 'foo' }; - callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 1 }); + mockClusterClient.callAsInternalUser + .withArgs('shield.deleteAccessToken') + .resolves({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { token: tokenPair.accessToken }, - }); + sinon.assert.calledOnce(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); }); it('invalidates only refresh token if only refresh token is provided', async () => { const tokenPair = { refreshToken: 'foo' }; - callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 1 }); + mockClusterClient.callAsInternalUser + .withArgs('shield.deleteAccessToken') + .resolves({ invalidated_tokens: 1 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledOnce(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { refresh_token: tokenPair.refreshToken }, - }); + sinon.assert.calledOnce(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); }); it('does not fail if none of the tokens were invalidated', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 0 }); + mockClusterClient.callAsInternalUser + .withArgs('shield.deleteAccessToken') + .resolves({ invalidated_tokens: 0 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledTwice(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { token: tokenPair.accessToken }, - }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { refresh_token: tokenPair.refreshToken }, - }); + sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); }); it('does not fail if more than one token per access or refresh token were invalidated', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 5 }); + mockClusterClient.callAsInternalUser + .withArgs('shield.deleteAccessToken') + .resolves({ invalidated_tokens: 5 }); await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - sinon.assert.calledTwice(callWithInternalUser); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { token: tokenPair.accessToken }, - }); - sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', { - body: { refresh_token: tokenPair.refreshToken }, - }); + sinon.assert.calledTwice(mockClusterClient.callAsInternalUser); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { token: tokenPair.accessToken } } + ); + sinon.assert.calledWithExactly( + mockClusterClient.callAsInternalUser, + 'shield.deleteAccessToken', + { body: { refresh_token: tokenPair.refreshToken } } + ); }); }); }); diff --git a/x-pack/legacy/plugins/security/server/lib/authentication/tokens.ts b/x-pack/plugins/security/server/authentication/tokens.ts similarity index 82% rename from x-pack/legacy/plugins/security/server/lib/authentication/tokens.ts rename to x-pack/plugins/security/server/authentication/tokens.ts index 6a57104fac7ed68..ae77d165a2ff5d9 100644 --- a/x-pack/legacy/plugins/security/server/lib/authentication/tokens.ts +++ b/x-pack/plugins/security/server/authentication/tokens.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger } from 'src/core/server'; +import { ClusterClient, Logger } from '../../../../../src/core/server'; import { getErrorStatusCode } from '../errors'; -import { SecurityClusterClient } from './index'; /** * Represents a pair of access and refresh tokens. @@ -30,12 +29,16 @@ export interface TokenPair { * various authentication providers. */ export class Tokens { + /** + * Logger instance bound to `tokens` context. + */ + private readonly logger: Logger; + constructor( - private readonly options: Readonly<{ - client: SecurityClusterClient; - log: Logger; - }> - ) {} + private readonly options: Readonly<{ client: PublicMethodsOf; logger: Logger }> + ) { + this.logger = options.logger; + } /** * Tries to exchange provided refresh token to a new pair of access and refresh tokens. @@ -47,15 +50,15 @@ export class Tokens { const { access_token: accessToken, refresh_token: refreshToken, - } = await this.options.client.callWithInternalUser('shield.getAccessToken', { + } = await this.options.client.callAsInternalUser('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: existingRefreshToken }, }); - this.options.log.debug('Access token has been successfully refreshed.'); + this.logger.debug('Access token has been successfully refreshed.'); return { accessToken, refreshToken }; } catch (err) { - this.options.log.debug(`Failed to refresh access token: ${err.message}`); + this.logger.debug(`Failed to refresh access token: ${err.message}`); // There are at least two common cases when refresh token request can fail: // 1. Refresh token is valid only for 24 hours and if it hasn't been used it expires. @@ -74,7 +77,7 @@ export class Tokens { // same refresh token multiple times yielding the same refreshed access/refresh token pair it's still possible // to hit the case when refresh token is no longer valid. if (getErrorStatusCode(err) === 400) { - this.options.log.debug('Refresh token is either expired or already used.'); + this.logger.debug('Refresh token is either expired or already used.'); return null; } @@ -89,28 +92,28 @@ export class Tokens { * @param [refreshToken] Optional refresh token to invalidate. */ public async invalidate({ accessToken, refreshToken }: Partial) { - this.options.log.debug('Invalidating access/refresh token pair.'); + this.logger.debug('Invalidating access/refresh token pair.'); let invalidationError; if (refreshToken) { let invalidatedTokensCount; try { - invalidatedTokensCount = (await this.options.client.callWithInternalUser( + invalidatedTokensCount = (await this.options.client.callAsInternalUser( 'shield.deleteAccessToken', { body: { refresh_token: refreshToken } } )).invalidated_tokens; } catch (err) { - this.options.log.debug(`Failed to invalidate refresh token: ${err.message}`); + this.logger.debug(`Failed to invalidate refresh token: ${err.message}`); // We don't re-throw the error here to have a chance to invalidate access token if it's provided. invalidationError = err; } if (invalidatedTokensCount === 0) { - this.options.log.debug('Refresh token was already invalidated.'); + this.logger.debug('Refresh token was already invalidated.'); } else if (invalidatedTokensCount === 1) { - this.options.log.debug('Refresh token has been successfully invalidated.'); + this.logger.debug('Refresh token has been successfully invalidated.'); } else if (invalidatedTokensCount > 1) { - this.options.log.debug( + this.logger.debug( `${invalidatedTokensCount} refresh tokens were invalidated, this is unexpected.` ); } @@ -119,21 +122,21 @@ export class Tokens { if (accessToken) { let invalidatedTokensCount; try { - invalidatedTokensCount = (await this.options.client.callWithInternalUser( + invalidatedTokensCount = (await this.options.client.callAsInternalUser( 'shield.deleteAccessToken', { body: { token: accessToken } } )).invalidated_tokens; } catch (err) { - this.options.log.debug(`Failed to invalidate access token: ${err.message}`); + this.logger.debug(`Failed to invalidate access token: ${err.message}`); invalidationError = err; } if (invalidatedTokensCount === 0) { - this.options.log.debug('Access token was already invalidated.'); + this.logger.debug('Access token was already invalidated.'); } else if (invalidatedTokensCount === 1) { - this.options.log.debug('Access token has been successfully invalidated.'); + this.logger.debug('Access token has been successfully invalidated.'); } else if (invalidatedTokensCount > 1) { - this.options.log.debug( + this.logger.debug( `${invalidatedTokensCount} access tokens were invalidated, this is unexpected.` ); } diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts new file mode 100644 index 000000000000000..ba4caee44155dac --- /dev/null +++ b/x-pack/plugins/security/server/config.test.ts @@ -0,0 +1,263 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('crypto', () => ({ randomBytes: jest.fn() })); + +import { of } from 'rxjs'; +import { createConfig, ConfigSchema } from './config'; + +const getContextMock = () => ({ + env: { mode: { name: 'development' as 'development', dev: true, prod: false } }, + logger: { get: jest.fn() }, + config: { create: jest.fn(), createIfExists: jest.fn() }, +}); + +describe('config schema', () => { + it('generates proper defaults', () => { + expect(ConfigSchema.validate({})).toMatchInlineSnapshot(` +Object { + "authc": Object { + "providers": Array [ + "basic", + ], + }, + "cookieName": "sid", + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "secureCookies": false, + "sessionTimeout": null, +} +`); + + expect(ConfigSchema.validate({}, { dist: false })).toMatchInlineSnapshot(` +Object { + "authc": Object { + "providers": Array [ + "basic", + ], + }, + "cookieName": "sid", + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "secureCookies": false, + "sessionTimeout": null, +} +`); + + expect(ConfigSchema.validate({}, { dist: true })).toMatchInlineSnapshot(` +Object { + "authc": Object { + "providers": Array [ + "basic", + ], + }, + "cookieName": "sid", + "secureCookies": false, + "sessionTimeout": null, +} +`); + }); + + it('should throw error if xpack.security.encryptionKey is less than 32 characters', () => { + expect(() => + ConfigSchema.validate({ encryptionKey: 'foo' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[encryptionKey]: value is [foo] but it must have a minimum length of [32]."` + ); + + expect(() => + ConfigSchema.validate({ encryptionKey: 'foo' }, { dist: true }) + ).toThrowErrorMatchingInlineSnapshot( + `"[encryptionKey]: value is [foo] but it must have a minimum length of [32]."` + ); + }); + + describe('authc.oidc', () => { + it(`returns a validation error when authc.providers is "['oidc']" and realm is unspecified`, async () => { + expect(() => + ConfigSchema.validate({ authc: { providers: ['oidc'] } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.oidc.realm]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + ConfigSchema.validate({ authc: { providers: ['oidc'], oidc: {} } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.oidc.realm]: expected value of type [string] but got [undefined]"` + ); + }); + + it(`is valid when authc.providers is "['oidc']" and realm is specified`, async () => { + expect( + ConfigSchema.validate({ + authc: { providers: ['oidc'], oidc: { realm: 'realm-1' } }, + }).authc + ).toMatchInlineSnapshot(` +Object { + "oidc": Object { + "realm": "realm-1", + }, + "providers": Array [ + "oidc", + ], +} +`); + }); + + it(`returns a validation error when authc.providers is "['oidc', 'basic']" and realm is unspecified`, async () => { + expect(() => + ConfigSchema.validate({ authc: { providers: ['oidc', 'basic'] } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.oidc.realm]: expected value of type [string] but got [undefined]"` + ); + }); + + it(`is valid when authc.providers is "['oidc', 'basic']" and realm is specified`, async () => { + expect( + ConfigSchema.validate({ + authc: { providers: ['oidc', 'basic'], oidc: { realm: 'realm-1' } }, + }).authc + ).toMatchInlineSnapshot(` +Object { + "oidc": Object { + "realm": "realm-1", + }, + "providers": Array [ + "oidc", + "basic", + ], +} +`); + }); + + it(`realm is not allowed when authc.providers is "['basic']"`, async () => { + expect(() => + ConfigSchema.validate({ authc: { providers: ['basic'], oidc: { realm: 'realm-1' } } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.oidc]: [authc.providers] should include \\"oidc\\"."` + ); + }); + }); + + describe('authc.saml', () => { + it('fails if authc.providers includes `saml`, but `saml.realm` is not specified', async () => { + expect(() => + ConfigSchema.validate({ authc: { providers: ['saml'] } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.saml.realm]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + ConfigSchema.validate({ authc: { providers: ['saml'], saml: {} } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.saml.realm]: expected value of type [string] but got [undefined]"` + ); + + expect( + ConfigSchema.validate({ + authc: { providers: ['saml'], saml: { realm: 'realm-1' } }, + }).authc + ).toMatchInlineSnapshot(` +Object { + "providers": Array [ + "saml", + ], + "saml": Object { + "realm": "realm-1", + }, +} +`); + }); + + it('`realm` is not allowed if saml provider is not enabled', async () => { + expect(() => + ConfigSchema.validate({ authc: { providers: ['basic'], saml: { realm: 'realm-1' } } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.saml]: [authc.providers] should include \\"saml\\"."` + ); + }); + }); +}); + +describe('createConfig()', async () => { + let contextMock: ReturnType; + beforeEach(() => { + contextMock = getContextMock(); + }); + + it('should log a warning and set xpack.security.encryptionKey if not set', async () => { + const mockLogWarn = jest.fn(); + contextMock.logger.get.mockReturnValue({ warn: mockLogWarn }); + + const mockRandomBytes = jest.requireMock('crypto').randomBytes; + mockRandomBytes.mockReturnValue('ab'.repeat(16)); + + contextMock.config.create.mockReturnValue(of({})); + + const config = await createConfig(contextMock, true); + expect(config).toEqual({ encryptionKey: 'ab'.repeat(16), secureCookies: true }); + + expect(mockLogWarn.mock.calls).toMatchInlineSnapshot(` +Array [ + Array [ + "Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.security.encryptionKey in kibana.yml", + ], +] +`); + }); + + it('should log a warning if SSL is not configured', async () => { + const mockLogWarn = jest.fn(); + contextMock.logger.get.mockReturnValue({ warn: mockLogWarn }); + + contextMock.config.create.mockReturnValue( + of({ encryptionKey: 'a'.repeat(32), secureCookies: false }) + ); + + const config = await createConfig(contextMock, false); + expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: false }); + + expect(mockLogWarn.mock.calls).toMatchInlineSnapshot(` +Array [ + Array [ + "Session cookies will be transmitted over insecure connections. This is not recommended.", + ], +] +`); + }); + + it('should log a warning if SSL is not configured yet secure cookies are being used', async () => { + const mockLogWarn = jest.fn(); + contextMock.logger.get.mockReturnValue({ warn: mockLogWarn }); + + contextMock.config.create.mockReturnValue( + of({ encryptionKey: 'a'.repeat(32), secureCookies: true }) + ); + + const config = await createConfig(contextMock, false); + expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: true }); + + expect(mockLogWarn.mock.calls).toMatchInlineSnapshot(` +Array [ + Array [ + "Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to function properly.", + ], +] +`); + }); + + it('should set xpack.security.secureCookies if SSL is configured', async () => { + const mockLogWarn = jest.fn(); + contextMock.logger.get.mockReturnValue({ warn: mockLogWarn }); + + contextMock.config.create.mockReturnValue( + of({ encryptionKey: 'a'.repeat(32), secureCookies: false }) + ); + + const config = await createConfig(contextMock, true); + expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: true }); + + expect(mockLogWarn).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts new file mode 100644 index 000000000000000..44f782317f23b05 --- /dev/null +++ b/x-pack/plugins/security/server/config.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import crypto from 'crypto'; +import { first } from 'rxjs/operators'; +import { schema, Type, TypeOf } from '@kbn/config-schema'; +import { PluginInitializerContext } from '../../../../src/core/server'; + +export type ConfigType = ReturnType extends Promise + ? P + : ReturnType; + +const providerOptionsSchema = (providerType: string, optionsSchema: Type) => + schema.conditional( + schema.siblingRef('providers'), + schema.arrayOf(schema.string(), { + validate: providers => (!providers.includes(providerType) ? 'error' : undefined), + }), + optionsSchema, + schema.maybe( + schema.any({ validate: () => `[authc.providers] should include "${providerType}".` }) + ) + ); + +export const ConfigSchema = schema.object( + { + cookieName: schema.string({ defaultValue: 'sid' }), + encryptionKey: schema.conditional( + schema.contextRef('dist'), + true, + schema.maybe(schema.string({ minLength: 32 })), + schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) + ), + sessionTimeout: schema.oneOf([schema.number(), schema.literal(null)], { defaultValue: null }), + secureCookies: schema.boolean({ defaultValue: false }), + authc: schema.object({ + providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }), + oidc: providerOptionsSchema('oidc', schema.maybe(schema.object({ realm: schema.string() }))), + saml: providerOptionsSchema('saml', schema.maybe(schema.object({ realm: schema.string() }))), + }), + }, + // This option should be removed as soon as we entirely migrate config from legacy Security plugin. + { allowUnknowns: true } +); + +export async function createConfig(context: PluginInitializerContext, isTLSEnabled: boolean) { + const logger = context.logger.get('config'); + const config = await context.config + .create>() + .pipe(first()) + .toPromise(); + + let encryptionKey = config.encryptionKey; + if (encryptionKey === undefined) { + logger.warn( + 'Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' + + 'restart, please set xpack.security.encryptionKey in kibana.yml' + ); + + encryptionKey = crypto.randomBytes(16).toString('hex'); + } + + let secureCookies = config.secureCookies; + if (!isTLSEnabled) { + if (secureCookies) { + logger.warn( + 'Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to ' + + 'function properly.' + ); + } else { + logger.warn( + 'Session cookies will be transmitted over insecure connections. This is not recommended.' + ); + } + } else if (!secureCookies) { + secureCookies = true; + } + + return { + ...config, + encryptionKey, + secureCookies, + }; +} diff --git a/x-pack/legacy/plugins/security/server/lib/errors.test.ts b/x-pack/plugins/security/server/errors.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/errors.test.ts rename to x-pack/plugins/security/server/errors.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/errors.ts b/x-pack/plugins/security/server/errors.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/errors.ts rename to x-pack/plugins/security/server/errors.ts diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts new file mode 100644 index 000000000000000..f3a95d0458d5330 --- /dev/null +++ b/x-pack/plugins/security/server/index.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { first, map } from 'rxjs/operators'; +import { + ClusterClient, + CoreSetup, + KibanaRequest, + Logger, + PluginInitializerContext, + RecursiveReadonly, + deepFreeze, +} from '../../../../src/core/server'; +import { XPackInfo } from '../../../legacy/plugins/xpack_main/server/lib/xpack_info'; +import { AuthenticatedUser } from '../common/model'; +import { Authenticator, setupAuthentication } from './authentication'; +import { ConfigSchema, createConfig } from './config'; + +// These exports are part of public Security plugin contract, any change in signature of exported +// functions or removal of exports should be considered as a breaking change. +export { wrapError } from './errors'; +export { + canRedirectRequest, + AuthenticationResult, + BasicCredentials, + DeauthenticationResult, +} from './authentication'; + +/** + * Describes a set of APIs that is available in the legacy platform only and required by this plugin + * to function properly. + */ +export interface LegacyAPI { + xpackInfo: XPackInfo; + isSystemAPIRequest: (request: KibanaRequest) => boolean; +} + +/** + * Describes public Security plugin contract returned at the `setup` stage. + */ +export interface SecuritySetupContract { + login: Authenticator['login']; + logout: Authenticator['logout']; + authenticate: Authenticator['authenticate']; + getAuthenticatedUser: (request: KibanaRequest) => Promise; + + config: RecursiveReadonly<{ + sessionTimeout: number | null; + secureCookies: boolean; + authc: { providers: ReadonlyArray }; + }>; + + registerLegacyAPI: (legacyAPI: LegacyAPI) => void; +} + +export const config = { schema: ConfigSchema }; + +class Plugin { + private readonly logger: Logger; + private clusterClient?: ClusterClient; + + private legacyAPI?: LegacyAPI; + private readonly getLegacyAPI = () => { + if (!this.legacyAPI) { + throw new Error('Legacy API is not registered!'); + } + return this.legacyAPI; + }; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.logger = this.initializerContext.logger.get(); + } + + public async setup(core: CoreSetup): Promise> { + const securityConfig = await createConfig(this.initializerContext, core.http.isTLSEnabled); + + const clusterClient = await core.elasticsearch.legacy.config$ + .pipe( + first(), + map(esLegacyConfig => + core.elasticsearch.createClient('security', { + ...esLegacyConfig, + plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')], + }) + ) + ) + .toPromise(); + this.clusterClient = clusterClient; + + const authenticator = await setupAuthentication({ + core, + config: securityConfig, + clusterClient, + loggers: this.initializerContext.logger, + getLegacyAPI: this.getLegacyAPI, + }); + + return deepFreeze({ + registerLegacyAPI: (legacyAPI: LegacyAPI) => (this.legacyAPI = legacyAPI), + + login: authenticator.login.bind(authenticator), + logout: authenticator.logout.bind(authenticator), + authenticate: authenticator.authenticate.bind(authenticator), + + getAuthenticatedUser: async (request: KibanaRequest) => { + const xpackInfo = this.getLegacyAPI().xpackInfo; + return xpackInfo && xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled() + ? null + : ((await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.authenticate')) as AuthenticatedUser); + }, + + // We should stop exposing this config as soon as only new platform plugin consumes it. The only + // exception may be `sessionTimeout` as other parts of the app may want to know it. + config: { + sessionTimeout: securityConfig.sessionTimeout, + secureCookies: securityConfig.secureCookies, + cookieName: securityConfig.cookieName, + authc: { providers: securityConfig.authc.providers }, + }, + }); + } + + public start() { + this.logger.debug('Starting plugin'); + } + + public stop() { + this.logger.debug('Stopping plugin'); + + if (this.clusterClient) { + this.clusterClient.close(); + this.clusterClient = undefined; + } + } +} + +export const plugin = (initializerContext: PluginInitializerContext) => + new Plugin(initializerContext);