From fc24a532a36e406ec8ee346a80746367ad39ae05 Mon Sep 17 00:00:00 2001 From: Yan Zeng <46499415+zengyan-amazon@users.noreply.github.com> Date: Thu, 9 Apr 2020 14:54:43 -0700 Subject: [PATCH] Initial PR for new security kibana plugin (#162) Initial commit of Security Kibana plugin --- .eslintrc.js | 8 + .gitignore | 5 + CODE_OF_CONDUCT.md | 2 + LICENSE | 176 ++++++++++++ NOTICE | 2 + README.md | 1 + common/index.ts | 17 ++ kibana.json | 9 + public/application.tsx | 44 +++ public/index.scss | 0 public/index.ts | 26 ++ public/plugin.ts | 51 ++++ public/types.ts | 24 ++ server/auth/types/basic/basic_auth.ts | 115 ++++++++ server/auth/types/basic/routes.ts | 271 ++++++++++++++++++ server/auth/user.ts | 36 +++ server/backend/opendistro_security_client.ts | 70 +++++ ...pendistro_security_configuration_plugin.ts | 219 ++++++++++++++ server/backend/opendistro_security_plugin.ts | 48 ++++ server/index.ts | 146 ++++++++++ server/plugin.ts | 96 +++++++ server/routes/index.ts | 267 +++++++++++++++++ server/routes/test_routes.ts | 70 +++++ server/session/security_cookie.ts | 52 ++++ server/types.ts | 19 ++ server/utils/filter_auth_headers.ts | 32 +++ 26 files changed, 1806 insertions(+) create mode 100644 .eslintrc.js create mode 100644 CODE_OF_CONDUCT.md create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 README.md create mode 100644 common/index.ts create mode 100644 kibana.json create mode 100644 public/application.tsx create mode 100644 public/index.scss create mode 100644 public/index.ts create mode 100644 public/plugin.ts create mode 100644 public/types.ts create mode 100644 server/auth/types/basic/basic_auth.ts create mode 100644 server/auth/types/basic/routes.ts create mode 100644 server/auth/user.ts create mode 100644 server/backend/opendistro_security_client.ts create mode 100644 server/backend/opendistro_security_configuration_plugin.ts create mode 100644 server/backend/opendistro_security_plugin.ts create mode 100644 server/index.ts create mode 100644 server/plugin.ts create mode 100644 server/routes/index.ts create mode 100644 server/routes/test_routes.ts create mode 100644 server/session/security_cookie.ts create mode 100644 server/types.ts create mode 100644 server/utils/filter_auth_headers.ts diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..d97d3568e --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,8 @@ + +module.exports = { + root: true, + extends: ['@elastic/eslint-config-kibana', 'plugin:@elastic/eui/recommended'], + rules: { + "@kbn/eslint/require-license-header": "off" + } +}; \ No newline at end of file diff --git a/.gitignore b/.gitignore index e69de29bb..8b8cc022b 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,5 @@ +npm-debug.log* +node_modules +/build/ +/public/app.css +target diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..4c45bccef --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,2 @@ +## Code of Conduct +This project has adopted an [Open Source Code of Conduct](https://opendistro.github.io/for-elasticsearch/codeofconduct.html). \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..2bb9ad240 --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..448b392c8 --- /dev/null +++ b/NOTICE @@ -0,0 +1,2 @@ + Open Distro for Elasticsearch Security Kibana Plugin +Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..80e80b193 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# security-kibana-plugin diff --git a/common/index.ts b/common/index.ts new file mode 100644 index 000000000..48edde340 --- /dev/null +++ b/common/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +export const PLUGIN_ID = 'opendistroSecurity'; +export const PLUGIN_NAME = 'opendistro_security'; diff --git a/kibana.json b/kibana.json new file mode 100644 index 000000000..53f266c19 --- /dev/null +++ b/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "security", + "version": "0.0.1", + "kibanaVersion": "8.0.0", + "configPath": ["opendistro_security"], + "requiredPlugins": ["navigation"], + "server": true, + "ui": true +} diff --git a/public/application.tsx b/public/application.tsx new file mode 100644 index 000000000..92ea36a5e --- /dev/null +++ b/public/application.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountContext, AppMountParameters, CoreStart } from '../../../src/core/public'; +import { AppPluginStartDependencies } from './types'; + +export function renderApp( + { notifications, http }: CoreStart, + { navigation }: AppPluginStartDependencies, + appMountContext: AppMountContext, + params: AppMountParameters + // basePath: string +) { + + ReactDOM.render( + // security application + (
+
), + params.element); + return () => ReactDOM.unmountComponentAtNode(params.element); +} + +function setBreadcrumbs(appMountContext: AppMountContext) { + appMountContext.core.chrome.setBreadcrumbs([ + { + text: "Security", + href: '', + } + ]); +} \ No newline at end of file diff --git a/public/index.scss b/public/index.scss new file mode 100644 index 000000000..e69de29bb diff --git a/public/index.ts b/public/index.ts new file mode 100644 index 000000000..a85c45b60 --- /dev/null +++ b/public/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import './index.scss'; + +import { OpendistroSecurityPlugin } from './plugin'; +import { PluginInitializerContext } from '../../../src/core/public'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. +export function plugin(initializerContext: PluginInitializerContext) { + return new OpendistroSecurityPlugin(initializerContext); +} +export { OpendistroSecurityPluginSetup, OpendistroSecurityPluginStart } from './types'; diff --git a/public/plugin.ts b/public/plugin.ts new file mode 100644 index 000000000..835f6e4de --- /dev/null +++ b/public/plugin.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { i18n } from '@kbn/i18n'; +import { AppMountParameters, CoreSetup, CoreStart, Plugin, PluginInitializerContext, AppMountContext } from '../../../src/core/public'; +import { + OpendistroSecurityPluginSetup, + OpendistroSecurityPluginStart, + AppPluginStartDependencies, +} from './types'; + +export class OpendistroSecurityPlugin + implements Plugin { + + constructor(private readonly initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup): OpendistroSecurityPluginSetup { + core.application.register({ + id: "opendistro_security", + title: "Security", + order: 1, + mount: async (context: AppMountContext, params: AppMountParameters) => { + const { renderApp } = await import('./application'); + const [coreStart, depsStart] = await core.getStartServices(); + return renderApp(coreStart, depsStart as AppPluginStartDependencies, context, params); + } + }); + + // Return methods that should be available to other plugins + return { + }; + } + + public start(core: CoreStart): OpendistroSecurityPluginStart { + return {}; + } + + public stop() {} +} diff --git a/public/types.ts b/public/types.ts new file mode 100644 index 000000000..e3c123c61 --- /dev/null +++ b/public/types.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { NavigationPublicPluginStart } from '../../../src/plugins/navigation/public'; + +export interface OpendistroSecurityPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface OpendistroSecurityPluginStart {} + +export interface AppPluginStartDependencies { + navigation: NavigationPublicPluginStart; +} diff --git a/server/auth/types/basic/basic_auth.ts b/server/auth/types/basic/basic_auth.ts new file mode 100644 index 000000000..717847ee1 --- /dev/null +++ b/server/auth/types/basic/basic_auth.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { AuthenticationHandler, SessionStorageFactory, IRouter, IClusterClient } from "../../../../../../src/core/server"; +import { SecurityPluginConfigType } from "../../.."; +import { SecuritySessionCookie } from "../../../session/security_cookie"; +import { CoreSetup } from "../../../../../../src/core/server"; +import _ from 'lodash'; +import { SecurityClient } from "../../../backend/opendistro_security_client"; +import { BasicAuthRoutes } from "./routes"; + +export class AuthConfig { + constructor( + public readonly authType: string, + public readonly authHeaderName: string, + public readonly allowedAdditionalAuthHeaders: string[], + public readonly authenticateFunction: () => void, + public readonly validateAvailableTenants: boolean, + public readonly validateAvailableRoles: boolean) { + } +} + +export class BasicAuthentication { + private static readonly AUTH_HEADER_NAME: string = 'authorization'; + private static readonly ALLOWED_ADDITIONAL_AUTH_HEADERS: string[] = ['security_impersonate_as']; + private static readonly ROUTES_TO_IGNORE: string[] = [ + '/bundles/app/security-login/bootstrap.js', + '/bundles/app/security-customerror/bootstrap.js', + '/', + '/app/login', + '/app/opendistro_login', + '/api/core/capabilities', + ]; + + // private readonly unauthenticatedRoutes: string[]; + private readonly securityClient: SecurityClient; + private readonly authConfig: AuthConfig; + + constructor(private readonly config: SecurityPluginConfigType, + private readonly sessionStorageFactory: SessionStorageFactory, + private readonly router: IRouter, + private readonly esClient: IClusterClient, + private readonly coreSetup: CoreSetup) { + + const multitenantEnabled = config.multitenancy.enabled; + + this.securityClient = new SecurityClient(this.esClient); + this.authConfig = new AuthConfig('basicauth', + BasicAuthentication.AUTH_HEADER_NAME, + BasicAuthentication.ALLOWED_ADDITIONAL_AUTH_HEADERS, + async () => { }, + multitenantEnabled, + true); + // this.unauthenticatedRoutes = this.config.auth.unauthenticated_routes; + + this.init(); + } + + private async init() { + const routes = new BasicAuthRoutes(this.router, this.config, this.sessionStorageFactory, this.securityClient, this.authConfig, this.coreSetup); + routes.setupRoutes(); + } + + /** + * Basic Authentication auth handler. Registered to core.http if basic authentication is enabled. + */ + authHandler: AuthenticationHandler = async (request, response, toolkit) => { + + if (BasicAuthentication.ROUTES_TO_IGNORE.includes(request.url.path)) { + return toolkit.authenticated(); + } + + if (this.config.auth.unauthenticated_routes.indexOf(request.url.path) > -1) { + // TODO: user kibana server user + return toolkit.authenticated(); + } + + let cookie: SecuritySessionCookie = undefined; + try { + cookie = await this.sessionStorageFactory.asScoped(request).get(); + // TODO: need to do auth for each all? + if (!cookie) { + return response.unauthorized(); + } + // set cookie to extend ttl + cookie.expiryTime = Date.now() + this.config.cookie.ttl; + this.sessionStorageFactory.asScoped(request).set(cookie); + + // pass credentials to request to Elasticsearch + const credentials = cookie.credentials; + return toolkit.authenticated({ + // state: credentials, + requestHeaders: { + 'authorization': credentials.authHeaderValue, + }, + }); + } catch (error) { + // TODO: switch to logger + console.log(`error: ${error}`); + // TODO: redirect using response? + } + } +} diff --git a/server/auth/types/basic/routes.ts b/server/auth/types/basic/routes.ts new file mode 100644 index 000000000..d43a9c5ca --- /dev/null +++ b/server/auth/types/basic/routes.ts @@ -0,0 +1,271 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { IRouter, SessionStorageFactory, KibanaRequest } from "../../../../../../src/core/server"; +import { SecuritySessionCookie } from "../../../session/security_cookie"; +import { SecurityPluginConfigType } from "../../.."; +import { AuthConfig } from "./basic_auth"; +import { filterAuthHeaders } from "../../../utils/filter_auth_headers"; +import { User } from "../../user"; +import { SecurityClient } from "../../../backend/opendistro_security_client"; +import { schema } from '@kbn/config-schema'; +import { CoreSetup } from "../../../../../../src/core/server"; + +export class BasicAuthRoutes { + constructor(private readonly router: IRouter, + private readonly config: SecurityPluginConfigType, + private readonly sessionStorageFactory: SessionStorageFactory, + private readonly securityClient: SecurityClient, + private readonly authConfig: AuthConfig, + private readonly coreSetup: CoreSetup) { + } + + public async setupRoutes() { + const PREFIX = ''; + + // if the user can be authenticated using auth headers, redirect to the next url, otherwise, render login page + this.router.get( + { + path: `${PREFIX}/login`, + validate: false, + options: { + authRequired: false, + } + }, + async (context, request, response) => { + try { + const alternativeHeaders = this.config.basicauth.alternative_login.headers; + if (alternativeHeaders && alternativeHeaders.length) { + let requestHeaders = Object.keys(request.headers).map(header => header.toLowerCase()); + let foundHeaders = alternativeHeaders.filter(header => requestHeaders.indexOf(header.toLowerCase()) > -1); + if (foundHeaders.length) { + await this.authenticateWithHeaders(request); + + let nextUrl = undefined; + if (request.url.query) { + // TODO: extract nextUrl from query string + response.redirected({ + headers: { + location: nextUrl, + } + }); + } + } + } + } catch (error) { + return response.redirected({ + headers: { + location: `/customerror`, + } + }) + } + + return response.ok({ + body: await context.core.rendering.render(), + headers: { + 'content-security-policy': this.coreSetup.http.csp.header, + } + }); // render login page here + } + ); + + // login using username and password + this.router.post( + { + path: `${PREFIX}/auth/login`, + validate: { + body: schema.object({ + username: schema.string(), + password: schema.string(), + }), + }, + options: { + authRequired: false, + } + }, + async (context, request, response) => { + const forbidden_usernames = this.config.auth.forbidden_usernames; + if (forbidden_usernames.indexOf(request.body.username) > -1) { + throw new Error('Invalid username or password'); // Cannot login using forbidden user name. + } + + // const authHeaderValue = Buffer.from(`${request.body.username}:${request.body.password}`).toString('base64'); + let user: User; + try { + user = await this.securityClient.authenticate(request, { username: request.body.username, password: request.body.password}); + } catch (error) { + return response.unauthorized({ + headers: { + "www-authenticate": error.message, + } + }) + } + + const encodedCredentials = Buffer.from(`${request.body.username}:${request.body.password}`).toString('base64'); + const sessionStorage: SecuritySessionCookie = { + username: user.username, + credentials: { + authHeaderValue: `Basic ${encodedCredentials}`, + }, + authType: 'basicauth', + isAnonymousAuth: false, + expiryTime: Date.now() + this.config.cookie.ttl, + } + this.sessionStorageFactory.asScoped(request).set(sessionStorage); + + if (this.config.multitenancy.enabled) { + let globalTenantEnabled = this.config.multitenancy.tenants.enable_global; + let privateTentantEnabled = this.config.multitenancy.tenants.enable_private; + let preferredTenants = this.config.multitenancy.tenants.preferred; + + // TODO: figureout selected tenant here and set it in the cookie + + return response.ok({ + body: { + username: user.username, + tenants: user.tenants, + roles: user.roles, + backendroles: user.backendRoles, + selectedTenants: '', // TODO: determine selected tenants + } + }) + } + return response.ok({ + body: { + username: user.username, + tenants: user.tenants, + } + }); + }, + ); + + // logout + this.router.post({ + path: `${PREFIX}/auth/logout`, + validate: false, + options: { + authRequired: false, + } + }, + async (context, request, response) => { + this.sessionStorageFactory.asScoped(request).clear(); + return response.ok(); // TODO: redirect to login? + }); + + // anonymous auth + this.router.get({ + path: `${PREFIX}/auth/anonymous`, + validate: false, + options: { + authRequired: false, + }, + }, + async (context, request, response) => { + if (this.config.auth.anonymous_auth_enabled) { + // TODO: implement anonymous auth for basic authentication + } else { + return response.redirected({ + headers: { + location: `${PREFIX}/login`, + } + }) + } + }); + + // renders custom error page + this.router.get({ + path: `${PREFIX}/customerror`, + validate: false, + options: { + authRequired: false, + } + }, + async (context, request, response) => { + return response.ok({ + body: '', + }) + }); + } + + // session storage plugin's authenticateWithHeaders() function + private async authenticateWithHeaders(request: KibanaRequest, credentials: any = {}, options: any = {}) { + try { + const additionalAuthHeaders = filterAuthHeaders(request.headers, this.authConfig.allowedAdditionalAuthHeaders); + let user = await this.securityClient.authenticateWithHeaders(request, credentials, additionalAuthHeaders); + + let session: SecuritySessionCookie = { + username: user.username, + credentials: credentials, + authType: this.authConfig.authType, + assignAuthHeader: false, + }; + let sessionTtl = this.config.session.ttl; + if (sessionTtl) { + session.expiryTime = Date.now() + sessionTtl; + } + const authResponse: AuthResponse = { + session, + user, + }; + + return this._handleAuthResponse(request, authResponse, additionalAuthHeaders); + } catch (error) { + this.sessionStorageFactory.asScoped(request).clear(); + throw error; + } + } + + private _handleAuthResponse(request: KibanaRequest, authResponse: AuthResponse, additionalAuthHeaders: any = {}) { + // Validate the user has at least one tenant + if (this.authConfig.validateAvailableTenants && this.config.multitenancy.enabled && + !this.config.multitenancy.tenants.enable_global) { + let privateTentantEnabled = this.config.multitenancy.tenants.enable_private; + let allTenants = authResponse.user.tenants; + + if (!this._hasAtLastOneTenant(authResponse.user, allTenants, privateTentantEnabled)) { + throw new Error('No tenant available for this user, please contact your system administrator.'); + } + } + + if (this.authConfig.validateAvailableRoles && (!authResponse.user.roles || authResponse.user.roles.length === 0)) { + throw new Error('No roles available for this user, please contact your system administrator.'); + } + + if (Object.keys(additionalAuthHeaders).length > 0) { + authResponse.session.additionalAuthHeaders = additionalAuthHeaders; + } + + this.sessionStorageFactory.asScoped(request).set(authResponse.session); + + return authResponse; + } + + private _hasAtLastOneTenant(user: User, allTenant: any, privateTentantEnabled: boolean): boolean { + if (privateTentantEnabled) { + return true; + } + + if (!allTenant || Object.keys(allTenant).length === 0 || + (Object.keys(allTenant).length === 1 && Object.keys(allTenant)[0] === user.username)) { + return false; + } + return true; + } +} + +class AuthResponse { + session: SecuritySessionCookie; + user: User; +} \ No newline at end of file diff --git a/server/auth/user.ts b/server/auth/user.ts new file mode 100644 index 000000000..fb51a3d65 --- /dev/null +++ b/server/auth/user.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +export class User { + readonly username: string; + readonly roles: Array; + readonly backendRoles: Array; + readonly tenants: Array; + readonly selectedTenant: string; + readonly credentials: any; + readonly proxyCredentials: any; + + constructor(username: string, roles: Array, backendRoles: Array, tenants: Array, + selectedTenant: string, credentials: any = undefined, proxyCredentials: any = undefined) { + this.username = username; + this.roles = roles; + this.backendRoles = backendRoles; + this.tenants = tenants; + this.selectedTenant = selectedTenant; + this.credentials = credentials; + this.proxyCredentials = proxyCredentials; + } +} + diff --git a/server/backend/opendistro_security_client.ts b/server/backend/opendistro_security_client.ts new file mode 100644 index 000000000..5c4c51ce3 --- /dev/null +++ b/server/backend/opendistro_security_client.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { IClusterClient, KibanaRequest } from "../../../../src/core/server"; +import { User} from "../auth/user"; + +export class SecurityClient { + constructor(private readonly esClient: IClusterClient) { + } + + public async authenticate(request: KibanaRequest, credentials: any): Promise { + const authHeader = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + try { + let esResponse = await this.esClient.asScoped(request).callAsCurrentUser('opendistro_security.authinfo', { + headers: { + authorization: `Basic ${authHeader}`, + } + }); + return new User(credentials.username, esResponse.roles, esResponse.backend_roles, esResponse.teanats, esResponse.user_requested_tenant, credentials, credentials); + } catch (error) { + throw new Error(error.message); + } + } + + public async authenticateWithHeader(request: KibanaRequest, headerName: string, headerValue: string, whitelistedHeadersAndValues: any, additionalAuthHeaders: any = {}): Promise { + try { + const credentials: any = { + headerName, + headerValue, + }; + let headers = {}; + if (headerValue) { + headers[headerName] = headerValue; + } + + // cannot get config elasticsearch.requestHeadersWhitelist from kibana.yml file in new platfrom + // meanwhile, do we really need to save all headers in cookie? + const esResponse = await this.esClient.asScoped(request).callAsCurrentUser('opendistro_security.authinfo', { + headers: headers + }); + return new User(esResponse.user_name, esResponse.roles, esResponse.backend_roles, esResponse.teanats, esResponse.user_requested_tenant, credentials, null); + } catch (error) { + throw new Error(error.message); + } + } + + public async authenticateWithHeaders(request: KibanaRequest, headerscredentials: any = {}, additionalAuthHeaders: any = {}) { + try { + const esResponse = await this.esClient.asScoped(request).callAsCurrentUser('opendistro_security.authinfo', { + headers: additionalAuthHeaders, + }); + return new User(esResponse.user_name, esResponse.roles, esResponse.backend_roles, esResponse.tenants, esResponse.user_requested_tenant); + } catch (error) { + throw new Error(error.message); + } + } + +} diff --git a/server/backend/opendistro_security_configuration_plugin.ts b/server/backend/opendistro_security_configuration_plugin.ts new file mode 100644 index 000000000..1b3f059d3 --- /dev/null +++ b/server/backend/opendistro_security_configuration_plugin.ts @@ -0,0 +1,219 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +export default function (Client: any, config: any, components: any) { + + const ca = components.clientAction.factory; + + Client.prototype.opendistro_security = components.clientAction.namespaceFactory(); + + Client.prototype.opendistro_security.prototype.restapiinfo = ca({ + url: { + fmt: '/_opendistro/_security/api/permissionsinfo' + } + }); + + Client.prototype.opendistro_security.prototype.indices = ca({ + url: { + fmt: '/_all/_mapping/field/*' + } + }); + /** + * Returns a Security resource configuration. + * + * Sample response: + * + * { + * "user": { + * "hash": "#123123" + * } + * } + */ + Client.prototype.opendistro_security.prototype.listResource = ca({ + url: { + fmt: '/_opendistro/_security/api/<%=resourceName%>', + req: { + resourceName: { + type: 'string', + required: true + } + } + } + }); + + /** + * Creates a Security resource instance. + * + * At the moment Security does not support conflict detection, + * so this method can be effectively used to both create and update resource. + * + * Sample response: + * + * { + * "status": "CREATED", + * "message": "User username created" + * } + */ + Client.prototype.opendistro_security.prototype.saveResource = ca({ + method: 'PUT', + needBody: true, + url: { + fmt: '/_opendistro/_security/api/<%=resourceName%>/<%=id%>', + req: { + resourceName: { + type: 'string', + required: true + }, + id: { + type: 'string', + required: true + } + } + } + }); + + /** + * Updates a resource. + * Resource identification is expected to computed from headers. Eg: auth headers. + * + * Sample response: + * { + * "status": "OK", + * "message": "Username updated." + * } + */ + Client.prototype.opendistro_security.prototype.saveResourceWithoutId = ca({ + method: 'PUT', + needBody: true, + url: { + fmt: '/_opendistro/_security/api/<%=resourceName%>', + req: { + resourceName: { + type: 'string', + required: true + } + } + } + }); + + /** + * Returns a Security resource instance. + * + * Sample response: + * + * { + * "user": { + * "hash": '#123123' + * } + * } + */ + Client.prototype.opendistro_security.prototype.getResource = ca({ + method: 'GET', + url: { + fmt: '/_opendistro/_security/api/<%=resourceName%>/<%=id%>', + req: { + resourceName: { + type: 'string', + required: true + }, + id: { + type: 'string', + required: true + } + } + } + }); + + /** + * Deletes a Security resource instance. + */ + Client.prototype.opendistro_security.prototype.deleteResource = ca({ + method: 'DELETE', + url: { + fmt: '/_opendistro/_security/api/<%=resourceName%>/<%=id%>', + req: { + resourceName: { + type: 'string', + required: true + }, + id: { + type: 'string', + required: true + } + } + } + }); + + + /** + * Deletes a Security resource instance. + */ + Client.prototype.opendistro_security.prototype.clearCache = ca({ + method: 'DELETE', + url: { + fmt: '/_opendistro/_security/api/cache', + } + }); + + Client.prototype.opendistro_security.prototype.validateDls = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_validate/query?explain=true' + } + }); + + Client.prototype.opendistro_security.prototype.getIndexMappings = ca({ + method: 'GET', + needBody: true, + url: { + fmt: '/<%=index%>/_mapping', + req: { + index: { + type: 'string', + required: true + } + } + } + }); + + + ///// + Client.prototype.opendistro_security.prototype.authinfo = ca({ + url: { + fmt: '/_opendistro/_security/authinfo' + } + }); + + Client.prototype.opendistro_security.prototype.multitenancyinfo = ca({ + url: { + fmt: '/_opendistro/_security/kibanainfo' + } + }); + + Client.prototype.opendistro_security.prototype.tenantinfo = ca({ + url: { + fmt: '/_opendistro/_security/tenantinfo' + } + }); + + Client.prototype.opendistro_security.prototype.authtoken = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_opendistro/_security/api/authtoken' + } + }); +} \ No newline at end of file diff --git a/server/backend/opendistro_security_plugin.ts b/server/backend/opendistro_security_plugin.ts new file mode 100644 index 000000000..1163c98d1 --- /dev/null +++ b/server/backend/opendistro_security_plugin.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +export default function (Client: any, config: any, components: any) { + + const ca = components.clientAction.factory; + + Client.prototype.opendistro_security = components.clientAction.namespaceFactory(); + + Client.prototype.opendistro_security.prototype.authinfo = ca({ + url: { + fmt: '/_opendistro/_security/authinfo' + } + }); + + Client.prototype.opendistro_security.prototype.multitenancyinfo = ca({ + url: { + fmt: '/_opendistro/_security/kibanainfo' + } + }); + + Client.prototype.opendistro_security.prototype.tenantinfo = ca({ + url: { + fmt: '/_opendistro/_security/tenantinfo' + } + }); + + Client.prototype.opendistro_security.prototype.authtoken = ca({ + method: 'POST', + needBody: true, + url: { + fmt: '/_opendistro/_security/api/authtoken' + } + }); + +}; \ No newline at end of file diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 000000000..033a49b93 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginInitializerContext, PluginConfigDescriptor } from '../../../src/core/server'; +import { OpendistroSecurityPlugin } from './plugin'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), + allow_client_certificates: schema.boolean({ defaultValue: false }), + readonly_mode: schema.object({ + roles: schema.arrayOf(schema.string(), { defaultValue: [] }), + }), + cookie: schema.object({ + secure: schema.boolean({ defaultValue: true }), + name: schema.string({ defaultValue: 'security_authentication' }), + password: schema.string({ defaultValue: 'security_cookie_default_password', minLength: 32 }), + ttl: schema.number({ defaultValue: 60 * 60 * 1000 }), + domain: schema.nullable(schema.string()), + isSameSite: schema.oneOf( + [ + schema.string({ + validate(value) { + if (value === 'Strict' || value === 'Lax') { + return `Allowed values of 'isSameSite' are ['Strict, 'Lax', true, false]`; + } + } + }), + schema.boolean() + ], { defaultValue: true }), + }), + session: schema.object({ + ttl: schema.number({ defaultValue: 60 * 60 * 1000 }), + keepalive: schema.boolean({ defaultValue: true }), + }), + auth: schema.object({ + type: schema.string({ + defaultValue: '', + validate(value) { + if (!['', 'basicauth', 'jwt', 'openid', 'saml', 'proxy', 'kerberos', 'proxycache'].includes(value)) { + return `allowed auth.type are ['', 'basicauth', 'jwt', 'openid', 'saml', 'proxy', 'kerberos', 'proxycache']`; + } + } + }), + anonymous_auth_enabled: schema.boolean({ defaultValue: false }), + unauthenticated_routes: schema.arrayOf(schema.string(), { defaultValue: ["/api/status"] }), + forbidden_usernames: schema.arrayOf(schema.string(), { defaultValue: [] }), + logout_url: schema.string({ defaultValue: '' }), + }), + basicauth: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + unauthenticated_routes: schema.arrayOf(schema.string(), { defaultValue: ["/api/status"] }), + forbidden_usernames: schema.arrayOf(schema.string(), { defaultValue: [] }), + header_trumps_session: schema.boolean({ defaultValue: false }), + alternative_login: schema.object({ + headers: schema.arrayOf(schema.string(), { defaultValue: [] }), + show_for_parameter: schema.string({ defaultValue: '' }), + valid_redirects: schema.arrayOf(schema.string(), { defaultValue: [] }), + button_text: schema.string({ defaultValue: 'Login with provider' }), + buttonstyle: schema.string({ defaultValue: '' }), + }), + loadbalancer_url: schema.maybe(schema.string()), + login: schema.object({ + title: schema.string({ defaultValue: 'Please login to Kibana' }), + subtitle: schema.string({ defaultValue: 'If you have forgotten your username or password, please ask your system administrator' }), + showbrandimage: schema.boolean({ defaultValue: true }), + brandimage: schema.string({ defaultValue: '' }), // TODO: update brand image + buttonstyle: schema.string({ defaultValue: '' }), + }), + }), + multitenancy: schema.maybe(schema.object({ + enabled: schema.boolean({ defaultValue: false }), + show_roles: schema.boolean({ defaultValue: false }), + enable_filter: schema.boolean({ defaultValue: false }), + debug: schema.boolean({ defaultValue: false }), + tenants: schema.object({ + enable_private: schema.boolean({ defaultValue: true }), + enable_global: schema.boolean({ defaultValue: true }), + preferred: schema.arrayOf(schema.string(), { defaultValue: [] }), + }), + })), + configuration: schema.maybe(schema.object({ + enabled: schema.boolean({ defaultValue: true }), + })), + accountinfo: schema.maybe(schema.object({ + enabled: schema.boolean({ defaultValue: false }), + })), + openid: schema.maybe(schema.object({ + connect_url: schema.maybe(schema.string()), + header: schema.string({ defaultValue: 'Authorization' }), + // TODO: test if siblingRef() works here + // client_id is required when auth.type is openid + client_id: schema.conditional(schema.siblingRef('auth.type'), 'openid', schema.string(), schema.maybe(schema.string())), + client_secret: schema.string({ defaultValue: '' }), + scope: schema.string({ defaultValue: 'openid profile email address phone' }), + base_redirect_url: schema.string({ defaultValue: '' }), + logout_url: schema.string({ defaultValue: '' }), + root_ca: schema.string({ defaultValue: '' }), + verify_hostnames: schema.boolean({ defaultValue: true }), + })), + proxycache: schema.maybe(schema.object({ + // when auth.type is proxycache, user_header, roles_header and proxy_header_ip are required + user_header: schema.conditional(schema.siblingRef('auth.type'), 'proxycache', schema.string(), schema.maybe(schema.string())), + roles_header: schema.conditional(schema.siblingRef('auth.type'), 'proxycache', schema.string(), schema.maybe(schema.string())), + proxy_header: schema.maybe(schema.string({ defaultValue: 'x-forwarded-for' })), + proxy_header_ip: schema.conditional(schema.siblingRef('auth.type'), 'proxycache', schema.string(), schema.maybe(schema.string())), + login_endpoint: schema.maybe(schema.string({ defaultValue: '' })), + })), + jwt: schema.maybe(schema.object({ + enabled: schema.boolean({ defaultValue: false }), + login_endpoint: schema.maybe(schema.string()), + url_param: schema.string({ defaultValue: 'authorization' }), + header: schema.string({ defaultValue: 'Authorization' }), + })), +}); + +export type SecurityPluginConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + cookie: true, + auth: true, + }, + schema: configSchema, +}; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new OpendistroSecurityPlugin(initializerContext); +} + +export { OpendistroSecurityPluginSetup, OpendistroSecurityPluginStart } from './types'; diff --git a/server/plugin.ts b/server/plugin.ts new file mode 100644 index 000000000..02ddb1cee --- /dev/null +++ b/server/plugin.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, + IClusterClient, + SessionStorageFactory, +} from '../../../src/core/server'; + +import { OpendistroSecurityPluginSetup, OpendistroSecurityPluginStart } from './types'; +import { defineRoutes } from './routes'; +import { SecurityPluginConfigType } from '.'; +import opendistro_security_configuratoin_plugin from './backend/opendistro_security_configuration_plugin'; +import opendistro_security_plugin from './backend/opendistro_security_plugin'; +import { first } from 'rxjs/operators'; +import { SecuritySessionCookie, getSecurityCookieOptions } from './session/security_cookie'; +import { BasicAuthentication } from './auth/types/basic/basic_auth'; +import { defineTestRoutes } from './routes/test_routes'; // TODO: remove this later + +export class OpendistroSecurityPlugin + implements Plugin { + private readonly logger: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public async setup(core: CoreSetup) { + this.logger.debug('opendistro_security: Setup'); + + const config$ = this.initializerContext.config.create(); + const config: SecurityPluginConfigType = await config$.pipe(first()).toPromise(); + + const router = core.http.createRouter(); + + const securityClient: IClusterClient = core.elasticsearch.createClient( + 'opendistro_security', + { + plugins: [ + opendistro_security_configuratoin_plugin, + // TODO need to add other endpoints such as multitenanct and other + // FIXME: having multiple plugins caused the extended endpoints not working, currently + // added all endpoints to opendistro_security_configuratoin_plugin as a workaround + // opendistro_security_plugin, + ], + } + ); + + const securitySessionStorageFactory: SessionStorageFactory + = await core.http.createCookieSessionStorageFactory(getSecurityCookieOptions(config)); + + + // Register server side APIs + defineRoutes(router, securityClient); + + // test routes + defineTestRoutes(router, securityClient, securitySessionStorageFactory, core); + + + // setup auth + if (config.auth.type === undefined || config.auth.type === '' || config.auth.type === 'basicauth') { + // TODO: switch implementation according to configurations + const auth = new BasicAuthentication(config, securitySessionStorageFactory, router, securityClient, core); + core.http.registerAuth(auth.authHandler); + } + + return { + config$, + securityConfigClient: securityClient, + }; + } + + public start(core: CoreStart) { + this.logger.debug('opendistro_security: Started'); + return {}; + } + + public stop() { } + +} diff --git a/server/routes/index.ts b/server/routes/index.ts new file mode 100644 index 000000000..fb6604b4c --- /dev/null +++ b/server/routes/index.ts @@ -0,0 +1,267 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { IRouter, IClusterClient } from '../../../../src/core/server'; +import { schema } from '@kbn/config-schema'; + +export function defineRoutes(router: IRouter, securityConfigClient: IClusterClient) { + const API_PREFIX: string = '/api/v1/opendistro_security'; + + const internalUserSchema = schema.object({ + description: schema.string(), + password: schema.string(), + backend_roles: schema.arrayOf(schema.string()), + // opendistro_security_roles: schema.nullable(schema.arrayOf(schema.string())), + attributes: schema.any(), + }); + + const actionGroupSchema = schema.object({ + description: schema.string(), + allowed_actions: schema.arrayOf(schema.string()), + type: schema.oneOf([schema.literal('cluster'), schema.literal('index'), schema.literal('kibana')]), + }); + + const roleMappingSchema = schema.object({ + description: schema.string(), + backend_roles: schema.arrayOf(schema.string()), + hosts: schema.arrayOf(schema.string()), + users: schema.arrayOf(schema.string()), + }); + + const roleSchema = schema.object({ + description: schema.string(), + cluster_permissions: schema.nullable(schema.arrayOf(schema.string())), + tenant_permissions: schema.arrayOf(schema.any()), + index_permissions: schema.arrayOf(schema.any()), + }); + + const tenantSchema = schema.object({ + description: schema.string(), + }); + + const accountSchema = schema.object({ + password: schema.string(), + current_password: schema.string(), + }); + + function validateRequestBody(resourceName: string, requestBody: any): any { + let inputSchema; + switch (resourceName) { + case 'internalusers': inputSchema = internalUserSchema; break; + case 'actiongroups': inputSchema = accountSchema; break; + case 'rolesmapping': inputSchema = roleMappingSchema; break; + case 'roles': inputSchema = roleSchema; break; + case 'tenants': inputSchema = tenantSchema; break; + case 'account': inputSchema = accountSchema; break; + default: throw new Error(`Unknown resource ${resourceName}`); + } + inputSchema.validate(requestBody); // throws error if validation fail + } + + router.get( + { + path: '/api/test/security_config', + validate: false, + }, + async (context, request, response) => { + const esResponse = await securityConfigClient.asScoped(request).callAsCurrentUser('opendistro_security.restapiinfo', { format: 'json' }); + return response.ok({ + body: esResponse, + }); + } + ); + + // list resources by resource name + router.get( + { + path: `${API_PREFIX}/configuration/{resourceName}`, + validate: { + params: schema.object({ + resourceName: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = securityConfigClient.asScoped(request); + let esResp; + try { + esResp = await client.callAsCurrentUser('opendistro_security.listResource', { resourceName: request.params.resourceName }); + return response.ok({ + body: { + total: Object.keys(esResp).length, + data: esResp, + } + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode, + body: error.message, + }); + } + }, + ); + + // get resource by resource name and id + router.get( + { + path: `${API_PREFIX}/configuration/{resourceName}/{id}`, + validate: { + params: schema.object({ + resourceName: schema.string(), + id: schema.string(), + }), + } + }, + async (context, request, response) => { + const client = securityConfigClient.asScoped(request); + let esResp; + try { + esResp = await client.callAsCurrentUser('opendistro_security.getResource', { resourceName: request.params.resourceName, id: request.params.id }); + return response.ok({ body: esResp[request.params.id] }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode, + body: error.message, + }); + } + }, + ); + + // delete resource by resource name and id + router.delete( + { + path: `${API_PREFIX}/configuration/{resourceName}/{id}`, + validate: { + params: schema.object({ + resourceName: schema.string(), + id: schema.string(), + }), + } + }, + async (context, request, response) => { + const client = securityConfigClient.asScoped(request); + let esResp; + try { + esResp = await client.callAsCurrentUser('opendistro_security.deleteResource', { resourceName: request.params.resourceName, id: request.params.id }); + return response.ok({ + body: { + message: esResp.message, + } + }) + } catch (error) { + return response.custom({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); + + // create new resource + router.post( + { + path: `${API_PREFIX}/configuration/{resourceName}`, + validate: { + params: schema.object({ + resourceName: schema.string(), + }), + body: schema.any(), + }, + }, + async (context, request, response) => { + try { + validateRequestBody(request.params.resourceName, request.body) + } catch (error) { + return response.badRequest({ body: error }); + } + const client = securityConfigClient.asScoped(request); + let esResp; + try { + esResp = await client.callAsCurrentUser('opendistro_security.saveResourceWithoutId', { resourceName: request.params.resourceName, body: request.body }); + return response.ok({ + body: { + message: esResp.message, + } + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); + + // update resource by Id + router.post( + { + path: `${API_PREFIX}/configuration/{resourceName}/{id}`, + validate: { + params: schema.object({ + resourceName: schema.string(), + id: schema.string(), + }), + body: schema.any(), + }, + }, + async (context, request, response) => { + try { + validateRequestBody(request.params.resourceName, request.body) + } catch (error) { + return response.badRequest({ body: error }); + } + const client = securityConfigClient.asScoped(request); + let esResp; + try { + esResp = await client.callAsCurrentUser('opendistro_security.saveResource', { resourceName: request.params.resourceName, id: request.params.id, body: request.body }); + return response.ok({ + body: { + message: esResp.message, + } + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); + + router.get( + { + path: `${API_PREFIX}/auth/authinfo`, + validate: false, + }, + async (context, request, response) => { + const client = securityConfigClient.asScoped(request); + let esResp; + try { + esResp = await client.callAsCurrentUser('opendistro_security.authinfo'); + + return response.ok({ + body: { + message: esResp.message, + } + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); +} diff --git a/server/routes/test_routes.ts b/server/routes/test_routes.ts new file mode 100644 index 000000000..493efd43e --- /dev/null +++ b/server/routes/test_routes.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { IRouter, IClusterClient, SessionStorageFactory } from "../../../../src/core/server"; +import { SecuritySessionCookie } from "../session/security_cookie"; +import { schema } from '@kbn/config-schema'; +import { User } from "../auth/user"; +import { SecurityClient } from "../backend/opendistro_security_client"; +import { CoreSetup } from "../../../../src/core/server"; + +export function defineTestRoutes(router: IRouter, + securityConfigClient: IClusterClient, + sessionStorageFactory: SessionStorageFactory, + core: CoreSetup) { + + router.get({ + path: `/test/login`, + validate: { + query: schema.object({ + username: schema.string(), + password: schema.string(), + }), + }, + options: { + authRequired: false, + } + }, + async (context, request, response) => { + sessionStorageFactory.asScoped(request).clear(); + const username = request.query.username; + const password = request.query.password; + let user: User; + try { + const securityClient = new SecurityClient(securityConfigClient); + user = await securityClient.authenticate(request, { username, password}); + const encodedCredentials = Buffer.from(`${username}:${password}`).toString('base64'); + const sessionStorage: SecuritySessionCookie = { + username: user.username, + credentials: { + authHeaderValue: `Basic ${encodedCredentials}`, + }, + authType: 'basicauth', + isAnonymousAuth: false, + expiryTime: Date.now() + 3600000000, + } + sessionStorageFactory.asScoped(request).set(sessionStorage); + return response.redirected({ + headers: { + location: `${core.http.basePath.serverBasePath}/app/kibana`, + } + }); + } catch (error) { + return response.unauthorized({ + body: `Failed to authenticate with username: '${username}' and password: '${password}'`, + }) + } + }); +} \ No newline at end of file diff --git a/server/session/security_cookie.ts b/server/session/security_cookie.ts new file mode 100644 index 000000000..22bb76648 --- /dev/null +++ b/server/session/security_cookie.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { SessionStorageCookieOptions, Logger } from "../../../../src/core/server"; +import { SecurityPluginConfigType } from ".."; + +export class SecuritySessionCookie { + // security_authentication + username: string; + credentials?: any; + authType?: string; + assignAuthHeader?: boolean; + isAnonymousAuth?: boolean; + expiryTime?: number; + additionalAuthHeaders?: any; + + // security_storage + tentent?: any; +} + +export function getSecurityCookieOptions(config: SecurityPluginConfigType): SessionStorageCookieOptions { + return { + name: config.cookie.name, + encryptionKey: config.cookie.password, + validate: (sessionStorage: SecuritySessionCookie) => { + if (sessionStorage === undefined + || sessionStorage.username === undefined + || sessionStorage.credentials === undefined) { + return { isValid: false }; + } + + if (sessionStorage.expiryTime === undefined + || new Date(sessionStorage.expiryTime) < new Date()) { + return { isValid: false }; + } + return { isValid: true, path: '/' }; + }, + isSecure: false, // config.cookie.secure, + } +} diff --git a/server/types.ts b/server/types.ts new file mode 100644 index 000000000..4522ca856 --- /dev/null +++ b/server/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface OpendistroSecurityPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface OpendistroSecurityPluginStart {} diff --git a/server/utils/filter_auth_headers.ts b/server/utils/filter_auth_headers.ts new file mode 100644 index 000000000..fd2dfb565 --- /dev/null +++ b/server/utils/filter_auth_headers.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import _ from 'lodash'; +import { Headers } from '../../../../src/core/server/http/router/headers'; + +export function filterAuthHeaders(originalHeaders: Headers, headersToKeep: string[]) { + const normalizeHeader = function (header: string) { + if (!header) { + return ''; + } + return header.trim().toLowerCase(); + }; + + const headersToKeepNormalized = headersToKeep.map(normalizeHeader); + const originalHeadersNormalized = _.mapKeys(originalHeaders, function (headerValue, headerName) { + return normalizeHeader(headerName); + }); + return _.pick(originalHeadersNormalized, headersToKeepNormalized); +}