From 481b4097e91c4f9e9de1a07491556636a812eb71 Mon Sep 17 00:00:00 2001 From: Chang Liu Date: Wed, 18 Jan 2023 16:16:14 -0800 Subject: [PATCH 1/6] Create security plugin Signed-off-by: Chang Liu --- src/plugins/security/common/index.ts | 19 ++++++ .../security/opensearch_dashboards.json | 9 +++ src/plugins/security/public/index.ts | 28 ++++++++ src/plugins/security/public/plugin.ts | 66 ++++++++++++++++++ src/plugins/security/public/types.ts | 39 +++++++++++ src/plugins/security/server/index.ts | 32 +++++++++ src/plugins/security/server/plugin.ts | 67 +++++++++++++++++++ src/plugins/security/server/types.ts | 24 +++++++ src/plugins/security/tsconfig.json | 32 +++++++++ 9 files changed, 316 insertions(+) create mode 100644 src/plugins/security/common/index.ts create mode 100644 src/plugins/security/opensearch_dashboards.json create mode 100644 src/plugins/security/public/index.ts create mode 100644 src/plugins/security/public/plugin.ts create mode 100644 src/plugins/security/public/types.ts create mode 100644 src/plugins/security/server/index.ts create mode 100644 src/plugins/security/server/plugin.ts create mode 100644 src/plugins/security/server/types.ts create mode 100644 src/plugins/security/tsconfig.json diff --git a/src/plugins/security/common/index.ts b/src/plugins/security/common/index.ts new file mode 100644 index 000000000000..3a05e2f4285f --- /dev/null +++ b/src/plugins/security/common/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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. + */ diff --git a/src/plugins/security/opensearch_dashboards.json b/src/plugins/security/opensearch_dashboards.json new file mode 100644 index 000000000000..1efcb783edcc --- /dev/null +++ b/src/plugins/security/opensearch_dashboards.json @@ -0,0 +1,9 @@ +{ + "id": "securityDashboards", + "version": "3.0.0.0", + "opensearchDashboardsVersion": "3.0.0", + "configPath": ["dashboards_security"], + "requiredPlugins": [], + "server": true, + "ui": true +} \ No newline at end of file diff --git a/src/plugins/security/public/index.ts b/src/plugins/security/public/index.ts new file mode 100644 index 000000000000..59d02d8718e3 --- /dev/null +++ b/src/plugins/security/public/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 { SecurityPlugin } from './plugin'; +import { PluginInitializerContext } from '../../../core/public'; + +// This exports static code and TypeScript types, +// as well as, OpenSearchDashboards Platform `plugin()` initializer. +export function plugin(initializerContext: PluginInitializerContext) { + return new SecurityPlugin(initializerContext); +} +export { SecurityPluginSetup, SecurityPluginStart } from './types'; diff --git a/src/plugins/security/public/plugin.ts b/src/plugins/security/public/plugin.ts new file mode 100644 index 000000000000..b1f33840bcae --- /dev/null +++ b/src/plugins/security/public/plugin.ts @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 { + AppMountParameters, + AppStatus, + AppUpdater, + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, +} from '../../../core/public'; +import { + SecurityPluginStartDependencies, + SecurityPluginSetup, + SecurityPluginStart, + SecurityPluginSetupDependencies, +} from './types'; + +const APP_ID_HOME = 'home'; +const APP_ID_DASHBOARDS = 'dashboards'; +// OpenSearchDashboards app is for legacy url migration +const APP_ID_OPENSEARCH_DASHBOARDS = 'kibana'; + +export class SecurityPlugin + implements + Plugin< + SecurityPluginSetup, + SecurityPluginStart, + SecurityPluginSetupDependencies, + SecurityPluginStartDependencies + > { + // @ts-ignore : initializerContext not used + constructor(private readonly initializerContext: PluginInitializerContext) {} + + public async setup( + core: CoreSetup, + deps: SecurityPluginSetupDependencies + ): Promise { + // Return methods that should be available to other plugins + return {}; + } + + public start(core: CoreStart, deps: SecurityPluginStartDependencies): SecurityPluginStart { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/security/public/types.ts b/src/plugins/security/public/types.ts new file mode 100644 index 000000000000..bb355e34a0cc --- /dev/null +++ b/src/plugins/security/public/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 '../../../plugins/navigation/public'; +import { + SavedObjectsManagementPluginSetup, + SavedObjectsManagementPluginStart, +} from '../../../plugins/saved_objects_management/public'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SecurityPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SecurityPluginStart {} + +export interface SecurityPluginSetupDependencies { + savedObjectsManagement: SavedObjectsManagementPluginSetup; +} + +export interface SecurityPluginStartDependencies { + navigation: NavigationPublicPluginStart; + savedObjectsManagement: SavedObjectsManagementPluginStart; +} diff --git a/src/plugins/security/server/index.ts b/src/plugins/security/server/index.ts new file mode 100644 index 000000000000..38f176e2a30d --- /dev/null +++ b/src/plugins/security/server/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 '@osd/config-schema'; +import { PluginInitializerContext, PluginConfigDescriptor } from '../../../core/server'; +import { SecurityPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, OpenSearchDashboards Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new SecurityPlugin(initializerContext); +} + +export { SecurityPluginSetup, SecurityPluginStart } from './types'; diff --git a/src/plugins/security/server/plugin.ts b/src/plugins/security/server/plugin.ts new file mode 100644 index 000000000000..a91897db2cf8 --- /dev/null +++ b/src/plugins/security/server/plugin.ts @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 { first } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, + ILegacyClusterClient, + SessionStorageFactory, + SharedGlobalConfig, +} from '../../../core/server'; + +import { SecurityPluginSetup, SecurityPluginStart } from './types'; + +export interface SecurityPluginRequestContext { + logger: Logger; +} + +export interface SecurityPluginRequestContext { + logger: Logger; +} + +export class SecurityPlugin implements Plugin { + private readonly logger: Logger; + + // @ts-ignore: property not initialzied in constructor + private securityClient: SecurityClient; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public async setup(core: CoreSetup) { + this.logger.debug('opendistro_security: Setup'); + return {}; + } + + // TODO: add more logs + public async start(core: CoreStart) { + this.logger.debug('dashboards_security: Started'); + + return {}; + } + + public stop() {} +} diff --git a/src/plugins/security/server/types.ts b/src/plugins/security/server/types.ts new file mode 100644 index 000000000000..17d712fa1c65 --- /dev/null +++ b/src/plugins/security/server/types.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 SecurityPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SecurityPluginStart {} diff --git a/src/plugins/security/tsconfig.json b/src/plugins/security/tsconfig.json new file mode 100644 index 000000000000..06bd62dbdce6 --- /dev/null +++ b/src/plugins/security/tsconfig.json @@ -0,0 +1,32 @@ +{ + // extend OpenSearchDashboards's tsconfig, or use your own settings + "extends": "../../tsconfig.json", + "compilerOptions": { + "esModuleInterop": true, + "outDir": "./target", + "allowUnusedLabels": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "skipLibCheck": true, + "alwaysStrict": false, + "noImplicitUseStrict": false, + }, + + // tell the TypeScript compiler where to find your source files + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "common/**/*.ts", + "../../typings/**/*", + ], + "exclude": [ + "public/**/*.test.ts", + "public/**/*.test.tsx", + "server/**/*.test.ts", + "../../src/**/*", + "**/request.ts", + "src/**/*" + ], +} \ No newline at end of file From 4929bb707fec0d7bf5efd27f447a0d77c3dcc11b Mon Sep 17 00:00:00 2001 From: Chang Liu Date: Mon, 6 Feb 2023 13:54:44 -0800 Subject: [PATCH 2/6] Remove the duplicate empty security plugin Signed-off-by: Chang Liu --- src/plugins/security/common/index.ts | 19 ------ .../security/opensearch_dashboards.json | 9 --- src/plugins/security/public/index.ts | 28 -------- src/plugins/security/public/plugin.ts | 66 ------------------ src/plugins/security/public/types.ts | 39 ----------- src/plugins/security/server/index.ts | 32 --------- src/plugins/security/server/plugin.ts | 67 ------------------- src/plugins/security/server/types.ts | 24 ------- src/plugins/security/tsconfig.json | 32 --------- 9 files changed, 316 deletions(-) delete mode 100644 src/plugins/security/common/index.ts delete mode 100644 src/plugins/security/opensearch_dashboards.json delete mode 100644 src/plugins/security/public/index.ts delete mode 100644 src/plugins/security/public/plugin.ts delete mode 100644 src/plugins/security/public/types.ts delete mode 100644 src/plugins/security/server/index.ts delete mode 100644 src/plugins/security/server/plugin.ts delete mode 100644 src/plugins/security/server/types.ts delete mode 100644 src/plugins/security/tsconfig.json diff --git a/src/plugins/security/common/index.ts b/src/plugins/security/common/index.ts deleted file mode 100644 index 3a05e2f4285f..000000000000 --- a/src/plugins/security/common/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -/* - * Copyright OpenSearch Contributors - * - * 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. - */ diff --git a/src/plugins/security/opensearch_dashboards.json b/src/plugins/security/opensearch_dashboards.json deleted file mode 100644 index 1efcb783edcc..000000000000 --- a/src/plugins/security/opensearch_dashboards.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "securityDashboards", - "version": "3.0.0.0", - "opensearchDashboardsVersion": "3.0.0", - "configPath": ["dashboards_security"], - "requiredPlugins": [], - "server": true, - "ui": true -} \ No newline at end of file diff --git a/src/plugins/security/public/index.ts b/src/plugins/security/public/index.ts deleted file mode 100644 index 59d02d8718e3..000000000000 --- a/src/plugins/security/public/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -/* - * Copyright OpenSearch Contributors - * - * 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 { SecurityPlugin } from './plugin'; -import { PluginInitializerContext } from '../../../core/public'; - -// This exports static code and TypeScript types, -// as well as, OpenSearchDashboards Platform `plugin()` initializer. -export function plugin(initializerContext: PluginInitializerContext) { - return new SecurityPlugin(initializerContext); -} -export { SecurityPluginSetup, SecurityPluginStart } from './types'; diff --git a/src/plugins/security/public/plugin.ts b/src/plugins/security/public/plugin.ts deleted file mode 100644 index b1f33840bcae..000000000000 --- a/src/plugins/security/public/plugin.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -/* - * Copyright OpenSearch Contributors - * - * 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 { - AppMountParameters, - AppStatus, - AppUpdater, - CoreSetup, - CoreStart, - Plugin, - PluginInitializerContext, -} from '../../../core/public'; -import { - SecurityPluginStartDependencies, - SecurityPluginSetup, - SecurityPluginStart, - SecurityPluginSetupDependencies, -} from './types'; - -const APP_ID_HOME = 'home'; -const APP_ID_DASHBOARDS = 'dashboards'; -// OpenSearchDashboards app is for legacy url migration -const APP_ID_OPENSEARCH_DASHBOARDS = 'kibana'; - -export class SecurityPlugin - implements - Plugin< - SecurityPluginSetup, - SecurityPluginStart, - SecurityPluginSetupDependencies, - SecurityPluginStartDependencies - > { - // @ts-ignore : initializerContext not used - constructor(private readonly initializerContext: PluginInitializerContext) {} - - public async setup( - core: CoreSetup, - deps: SecurityPluginSetupDependencies - ): Promise { - // Return methods that should be available to other plugins - return {}; - } - - public start(core: CoreStart, deps: SecurityPluginStartDependencies): SecurityPluginStart { - return {}; - } - - public stop() {} -} diff --git a/src/plugins/security/public/types.ts b/src/plugins/security/public/types.ts deleted file mode 100644 index bb355e34a0cc..000000000000 --- a/src/plugins/security/public/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -/* - * Copyright OpenSearch Contributors - * - * 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 '../../../plugins/navigation/public'; -import { - SavedObjectsManagementPluginSetup, - SavedObjectsManagementPluginStart, -} from '../../../plugins/saved_objects_management/public'; - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SecurityPluginSetup {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SecurityPluginStart {} - -export interface SecurityPluginSetupDependencies { - savedObjectsManagement: SavedObjectsManagementPluginSetup; -} - -export interface SecurityPluginStartDependencies { - navigation: NavigationPublicPluginStart; - savedObjectsManagement: SavedObjectsManagementPluginStart; -} diff --git a/src/plugins/security/server/index.ts b/src/plugins/security/server/index.ts deleted file mode 100644 index 38f176e2a30d..000000000000 --- a/src/plugins/security/server/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -/* - * Copyright OpenSearch Contributors - * - * 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 '@osd/config-schema'; -import { PluginInitializerContext, PluginConfigDescriptor } from '../../../core/server'; -import { SecurityPlugin } from './plugin'; - -// This exports static code and TypeScript types, -// as well as, OpenSearchDashboards Platform `plugin()` initializer. - -export function plugin(initializerContext: PluginInitializerContext) { - return new SecurityPlugin(initializerContext); -} - -export { SecurityPluginSetup, SecurityPluginStart } from './types'; diff --git a/src/plugins/security/server/plugin.ts b/src/plugins/security/server/plugin.ts deleted file mode 100644 index a91897db2cf8..000000000000 --- a/src/plugins/security/server/plugin.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -/* - * Copyright OpenSearch Contributors - * - * 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 { first } from 'rxjs/operators'; -import { Observable } from 'rxjs'; -import { - PluginInitializerContext, - CoreSetup, - CoreStart, - Plugin, - Logger, - ILegacyClusterClient, - SessionStorageFactory, - SharedGlobalConfig, -} from '../../../core/server'; - -import { SecurityPluginSetup, SecurityPluginStart } from './types'; - -export interface SecurityPluginRequestContext { - logger: Logger; -} - -export interface SecurityPluginRequestContext { - logger: Logger; -} - -export class SecurityPlugin implements Plugin { - private readonly logger: Logger; - - // @ts-ignore: property not initialzied in constructor - private securityClient: SecurityClient; - - constructor(private readonly initializerContext: PluginInitializerContext) { - this.logger = initializerContext.logger.get(); - } - - public async setup(core: CoreSetup) { - this.logger.debug('opendistro_security: Setup'); - return {}; - } - - // TODO: add more logs - public async start(core: CoreStart) { - this.logger.debug('dashboards_security: Started'); - - return {}; - } - - public stop() {} -} diff --git a/src/plugins/security/server/types.ts b/src/plugins/security/server/types.ts deleted file mode 100644 index 17d712fa1c65..000000000000 --- a/src/plugins/security/server/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -/* - * Copyright OpenSearch Contributors - * - * 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 SecurityPluginSetup {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SecurityPluginStart {} diff --git a/src/plugins/security/tsconfig.json b/src/plugins/security/tsconfig.json deleted file mode 100644 index 06bd62dbdce6..000000000000 --- a/src/plugins/security/tsconfig.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - // extend OpenSearchDashboards's tsconfig, or use your own settings - "extends": "../../tsconfig.json", - "compilerOptions": { - "esModuleInterop": true, - "outDir": "./target", - "allowUnusedLabels": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "skipLibCheck": true, - "alwaysStrict": false, - "noImplicitUseStrict": false, - }, - - // tell the TypeScript compiler where to find your source files - "include": [ - "index.ts", - "public/**/*.ts", - "public/**/*.tsx", - "server/**/*.ts", - "common/**/*.ts", - "../../typings/**/*", - ], - "exclude": [ - "public/**/*.test.ts", - "public/**/*.test.tsx", - "server/**/*.test.ts", - "../../src/**/*", - "**/request.ts", - "src/**/*" - ], -} \ No newline at end of file From 60f1986908dd31e4325f63d12ec9e7a22c3c0f7e Mon Sep 17 00:00:00 2001 From: Aozixuan Priscilla Guan Date: Mon, 6 Feb 2023 16:20:33 -0600 Subject: [PATCH 3/6] Basic and OIDC Authentication POC, Refactor configuration Signed-off-by: Aozixuan Priscilla Guan --- .../dashboards_security/common/index.ts | 36 +++ .../opensearch_dashboards.json | 8 + .../public/apps/login/_index.scss | 25 ++ .../public/apps/login/login-app.tsx | 21 ++ .../public/apps/login/login-page.tsx | 184 +++++++++++++ .../public/apps/logout/logout-app.tsx | 38 +++ .../public/apps/logout/logout-page.tsx | 34 +++ .../public/assets/get_started.svg | 78 ++++++ .../public/assets/opensearch_logo_h.svg | 10 + .../dashboards_security/public/index.ts | 13 + .../dashboards_security/public/plugin.ts | 51 ++++ .../dashboards_security/public/types.ts | 61 +++++ .../public/utils/auth_utils.ts | 40 +++ .../public/utils/request_utils.ts | 25 ++ .../server/auth/auth_handler_factory.ts | 42 +++ .../server/auth/types/authentication_type.ts | 150 +++++++++++ .../server/auth/types/basic/basic_auth.ts | 106 ++++++++ .../server/auth/types/basic/routes.ts | 132 +++++++++ .../server/auth/types/basic/user_bank.ts | 43 +++ .../server/auth/types/index.ts | 8 + .../server/auth/types/multiple/multi_auth.ts | 165 ++++++++++++ .../server/auth/types/multiple/routes.ts | 52 ++++ .../server/auth/types/openid/openid_auth.ts | 203 ++++++++++++++ .../server/auth/types/openid/routes.ts | 240 +++++++++++++++++ .../dashboards_security/server/auth/user.ts | 9 + .../server/configuration/auth_config.ts | 19 ++ .../dashboards_security/server/index.ts | 236 ++++++++++++++++ .../dashboards_security/server/plugin.ts | 60 +++++ .../server/session/security_cookie.ts | 70 +++++ .../dashboards_security/server/types.ts | 9 + .../server/utils/auth_util.ts | 97 +++++++ .../server/utils/common_util.ts | 255 ++++++++++++++++++ .../server/utils/next_url.ts | 59 ++++ 33 files changed, 2579 insertions(+) create mode 100644 src/plugins/dashboards_security/common/index.ts create mode 100644 src/plugins/dashboards_security/opensearch_dashboards.json create mode 100644 src/plugins/dashboards_security/public/apps/login/_index.scss create mode 100644 src/plugins/dashboards_security/public/apps/login/login-app.tsx create mode 100644 src/plugins/dashboards_security/public/apps/login/login-page.tsx create mode 100644 src/plugins/dashboards_security/public/apps/logout/logout-app.tsx create mode 100644 src/plugins/dashboards_security/public/apps/logout/logout-page.tsx create mode 100644 src/plugins/dashboards_security/public/assets/get_started.svg create mode 100644 src/plugins/dashboards_security/public/assets/opensearch_logo_h.svg create mode 100644 src/plugins/dashboards_security/public/index.ts create mode 100644 src/plugins/dashboards_security/public/plugin.ts create mode 100644 src/plugins/dashboards_security/public/types.ts create mode 100644 src/plugins/dashboards_security/public/utils/auth_utils.ts create mode 100644 src/plugins/dashboards_security/public/utils/request_utils.ts create mode 100644 src/plugins/dashboards_security/server/auth/auth_handler_factory.ts create mode 100644 src/plugins/dashboards_security/server/auth/types/authentication_type.ts create mode 100644 src/plugins/dashboards_security/server/auth/types/basic/basic_auth.ts create mode 100755 src/plugins/dashboards_security/server/auth/types/basic/routes.ts create mode 100644 src/plugins/dashboards_security/server/auth/types/basic/user_bank.ts create mode 100644 src/plugins/dashboards_security/server/auth/types/index.ts create mode 100644 src/plugins/dashboards_security/server/auth/types/multiple/multi_auth.ts create mode 100644 src/plugins/dashboards_security/server/auth/types/multiple/routes.ts create mode 100644 src/plugins/dashboards_security/server/auth/types/openid/openid_auth.ts create mode 100644 src/plugins/dashboards_security/server/auth/types/openid/routes.ts create mode 100644 src/plugins/dashboards_security/server/auth/user.ts create mode 100644 src/plugins/dashboards_security/server/configuration/auth_config.ts create mode 100644 src/plugins/dashboards_security/server/index.ts create mode 100644 src/plugins/dashboards_security/server/plugin.ts create mode 100644 src/plugins/dashboards_security/server/session/security_cookie.ts create mode 100644 src/plugins/dashboards_security/server/types.ts create mode 100644 src/plugins/dashboards_security/server/utils/auth_util.ts create mode 100644 src/plugins/dashboards_security/server/utils/common_util.ts create mode 100644 src/plugins/dashboards_security/server/utils/next_url.ts diff --git a/src/plugins/dashboards_security/common/index.ts b/src/plugins/dashboards_security/common/index.ts new file mode 100644 index 000000000000..c544cb202650 --- /dev/null +++ b/src/plugins/dashboards_security/common/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PLUGIN_ID = 'opensearchDashboardsSecurity'; +export const PLUGIN_NAME = 'security-dashboards-plugin'; + +export const APP_ID_LOGIN = 'login'; + +export const API_PREFIX = '/api/v1'; +export const CONFIGURATION_API_PREFIX = 'configuration'; +export const API_ENDPOINT_AUTHINFO = API_PREFIX + '/auth/authinfo'; +export const API_ENDPOINT_AUTHTYPE = API_PREFIX + '/auth/type'; +export const LOGIN_PAGE_URI = '/app/' + APP_ID_LOGIN; +export const API_AUTH_LOGIN = '/auth/login'; +export const API_AUTH_LOGOUT = '/auth/logout'; +export const ANONYMOUS_AUTH_LOGIN = '/auth/anonymous'; +export const SAML_AUTH_LOGIN_WITH_FRAGMENT = '/auth/saml/captureUrlFragment?nextUrl=%2F'; + +export const OPENID_AUTH_LOGOUT = '/auth/openid/logout'; +export const SAML_AUTH_LOGOUT = '/auth/saml/logout'; +export const ANONYMOUS_AUTH_LOGOUT = '/auth/anonymous/logout'; + +export const AUTH_HEADER_NAME = 'authorization'; +export const AUTH_GRANT_TYPE = 'authorization_code'; +export const AUTH_RESPONSE_TYPE = 'code'; + +export enum AuthType { + BASIC = 'basicauth', + OIDC = 'oidc', + JWT = 'jwt', + SAML = 'saml', + PROXY = 'proxy', + ANONYMOUS = 'anonymous', +} diff --git a/src/plugins/dashboards_security/opensearch_dashboards.json b/src/plugins/dashboards_security/opensearch_dashboards.json new file mode 100644 index 000000000000..76c5e87a3224 --- /dev/null +++ b/src/plugins/dashboards_security/opensearch_dashboards.json @@ -0,0 +1,8 @@ +{ + "id": "dashboardsSecurity", + "version": "opensearchDashboards", + "configPath": ["dashboards_security"], + "requiredPlugins": ["navigation"], + "server": true, + "ui": true +} diff --git a/src/plugins/dashboards_security/public/apps/login/_index.scss b/src/plugins/dashboards_security/public/apps/login/_index.scss new file mode 100644 index 000000000000..5b48bd965e02 --- /dev/null +++ b/src/plugins/dashboards_security/public/apps/login/_index.scss @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * + * 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. + */ + +.login-wrapper { + margin: 10% auto; + width: 350px; + padding: 1rem; + position: relative; +} + +.btn-login { + width: 100%; +} diff --git a/src/plugins/dashboards_security/public/apps/login/login-app.tsx b/src/plugins/dashboards_security/public/apps/login/login-app.tsx new file mode 100644 index 000000000000..bdbd743c850b --- /dev/null +++ b/src/plugins/dashboards_security/public/apps/login/login-app.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './_index.scss'; +// @ts-ignore : Component not used +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters, CoreStart } from '../../../../../core/public'; +import { ClientConfigType } from '../../types'; +import { LoginPage } from './login-page'; + +export function renderApp( + coreStart: CoreStart, + params: AppMountParameters, + config: ClientConfigType +) { + ReactDOM.render(, params.element); + return () => ReactDOM.unmountComponentAtNode(params.element); +} diff --git a/src/plugins/dashboards_security/public/apps/login/login-page.tsx b/src/plugins/dashboards_security/public/apps/login/login-page.tsx new file mode 100644 index 000000000000..01bab7cefd2d --- /dev/null +++ b/src/plugins/dashboards_security/public/apps/login/login-page.tsx @@ -0,0 +1,184 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiText, + EuiFieldText, + EuiIcon, + EuiSpacer, + EuiButton, + EuiImage, + EuiListGroup, + EuiForm, + EuiFormRow, +} from '@elastic/eui'; +import { AuthType } from 'src/plugins/dashboards_security/common'; +import { ESMap } from 'typescript'; +import { map } from 'bluebird'; +import { CoreStart } from '../../../../../core/public'; +import { ClientConfigType } from '../../types'; +import defaultBrandImage from '../../assets/opensearch_logo_h.svg'; +import { validateCurrentPassword } from '../../utils/auth_utils'; + +interface LoginButtonConfig { + buttonname: string; + showbrandimage: boolean; + brandimage: string; + buttonstyle: string; +} + +interface LoginPageDeps { + http: CoreStart['http']; + config: ClientConfigType; +} + +function redirect(serverBasePath: string) { + // navigate to nextUrl + const urlParams = new URLSearchParams(window.location.search); + let nextUrl = urlParams.get('nextUrl'); + if (!nextUrl || nextUrl.toLowerCase().includes('//')) { + nextUrl = serverBasePath + '/'; + } + window.location.href = nextUrl + window.location.hash; +} + +export function LoginPage(props: LoginPageDeps) { + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [loginFailed, setloginFailed] = useState(false); + const [loginError, setloginError] = useState(''); + const [usernameValidationFailed, setUsernameValidationFailed] = useState(false); + const [passwordValidationFailed, setPasswordValidationFailed] = useState(false); + + let errorLabel: any = null; + if (loginFailed) { + errorLabel = ( + + {loginError} + + ); + } + + // @ts-ignore : Parameter 'e' implicitly has an 'any' type. + const handleSubmit = async (e) => { + e.preventDefault(); + + // Clear errors + setloginFailed(false); + setUsernameValidationFailed(false); + setPasswordValidationFailed(false); + + // Form validation + if (username === '') { + setUsernameValidationFailed(true); + return; + } + + if (password === '') { + setPasswordValidationFailed(true); + return; + } + try { + await validateCurrentPassword(props.http, username, password); + redirect(props.http.basePath.serverBasePath); + } catch (error) { + setloginFailed(true); + setloginError('Invalid username or password. Please try again.'); + return; + } + }; + + // TODO: Get brand image from server config + return ( + + {props.config.ui.basicauth.login.showbrandimage && ( + + )} + + + {props.config.ui.basicauth.login.title || 'Please login to OpenSearch Dashboards'} + + + + {props.config.ui.basicauth.login.subtitle || + 'If you have forgotten your username or password, please ask your system administrator'} + + + + + } + onChange={(e) => setUsername(e.target.value)} + value={username} + /> + + + } + type="password" + onChange={(e) => setPassword(e.target.value)} + value={password} + /> + + + + Log In + + + + + + + Login with OKTA (OIDC) + + + + + + Login with Google (OIDC) + + + {errorLabel} + + ); +} diff --git a/src/plugins/dashboards_security/public/apps/logout/logout-app.tsx b/src/plugins/dashboards_security/public/apps/logout/logout-app.tsx new file mode 100644 index 000000000000..0d5689a86247 --- /dev/null +++ b/src/plugins/dashboards_security/public/apps/logout/logout-app.tsx @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 { CoreStart } from 'opensearch-dashboards/public'; +import { ClientConfigType } from '../../types'; +import { LogoutPage } from './logout-page'; + +export async function setupLogoutButton(coreStart: CoreStart, config: ClientConfigType) { + coreStart.chrome.navControls.registerRight({ + order: 2000, + mount: (element: HTMLElement) => { + ReactDOM.render( + , + element + ); + return () => ReactDOM.unmountComponentAtNode(element); + }, + }); +} diff --git a/src/plugins/dashboards_security/public/apps/logout/logout-page.tsx b/src/plugins/dashboards_security/public/apps/logout/logout-page.tsx new file mode 100644 index 000000000000..8dac9368d020 --- /dev/null +++ b/src/plugins/dashboards_security/public/apps/logout/logout-page.tsx @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 { EuiButtonEmpty } from '@elastic/eui'; +import { HttpStart } from 'opensearch-dashboards/public'; +import { logout } from '../../utils/auth_utils'; + +export function LogoutPage(props: { http: HttpStart; logoutUrl?: string }) { + return ( +
+ logout(props.http, props.logoutUrl)}> + Log out + +
+ ); +} diff --git a/src/plugins/dashboards_security/public/assets/get_started.svg b/src/plugins/dashboards_security/public/assets/get_started.svg new file mode 100644 index 000000000000..842164f5c2b3 --- /dev/null +++ b/src/plugins/dashboards_security/public/assets/get_started.svg @@ -0,0 +1,78 @@ + + + get_started + + + + + + + + + + + + + + + + + + + + + + Map backend roles + + + + + + + + + + + + + + Map internal users + + + + + + + + + + + + Role + + + + + + + + + + + + (authc & authz) + + + Backends + + + + + + + + + + + + \ No newline at end of file diff --git a/src/plugins/dashboards_security/public/assets/opensearch_logo_h.svg b/src/plugins/dashboards_security/public/assets/opensearch_logo_h.svg new file mode 100644 index 000000000000..cb329cadfb40 --- /dev/null +++ b/src/plugins/dashboards_security/public/assets/opensearch_logo_h.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/plugins/dashboards_security/public/index.ts b/src/plugins/dashboards_security/public/index.ts new file mode 100644 index 000000000000..d6cd9167faf2 --- /dev/null +++ b/src/plugins/dashboards_security/public/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginInitializerContext } from 'opensearch-dashboards/public'; +import { SecurityPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new SecurityPlugin(initializerContext); +} + +export { SecurityPluginSetup, SecurityPluginStart } from './types'; diff --git a/src/plugins/dashboards_security/public/plugin.ts b/src/plugins/dashboards_security/public/plugin.ts new file mode 100644 index 000000000000..144047ec7bfc --- /dev/null +++ b/src/plugins/dashboards_security/public/plugin.ts @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Plugin, + PluginInitializerContext, + CoreSetup, + CoreStart, + AppMountParameters, +} from 'opensearch-dashboards/public'; +import { ClientConfigType, SecurityPluginSetup, SecurityPluginStart } from './types'; +import { APP_ID_LOGIN, LOGIN_PAGE_URI } from '../common'; +import { setupLogoutButton } from './apps/logout/logout-app'; + +export class SecurityPlugin implements Plugin { + // @ts-ignore : initializerContext not used + constructor(private readonly initializerContext: PluginInitializerContext) {} + + public async setup(core: CoreSetup): Promise { + const config = this.initializerContext.config.get(); + + /* Privilege evaluation:: check the user's permissiion. Regsiter application based on user's permission + * This setep need to be implemented + */ + core.application.register({ + id: APP_ID_LOGIN, + title: 'Security', + chromeless: true, + appRoute: LOGIN_PAGE_URI, + mount: async (params: AppMountParameters) => { + const { renderApp } = await import('./apps/login/login-app'); + // @ts-ignore depsStart not used. + const [coreStart, depsStart] = await core.getStartServices(); + return renderApp(coreStart, params, config); + }, + }); + + return {}; + } + + public start(core: CoreStart): SecurityPluginStart { + const config = this.initializerContext.config.get(); + setupLogoutButton(core, config); + + return {}; + } + + public stop() {} +} diff --git a/src/plugins/dashboards_security/public/types.ts b/src/plugins/dashboards_security/public/types.ts new file mode 100644 index 000000000000..e0b29bb4d37e --- /dev/null +++ b/src/plugins/dashboards_security/public/types.ts @@ -0,0 +1,61 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { map } from 'rxjs/operators'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SecurityPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SecurityPluginStart {} + +export interface AuthInfo { + user_name: string; + entitlements: { + [entitlement: string]: boolean; + }; +} + +export interface ClientConfigType { + readonly_mode: { + roles: string[]; + }; + ui: { + basicauth: { + login: { + title: string; + subtitle: string; + showbrandimage: boolean; + brandimage: string; + buttonstyle: string; + }; + }; + openid: { + login: { + buttonname: string; + showbrandimage: boolean; + brandimage: string; + buttonstyle: string; + }; + }; + saml: { + login: { + buttonname: string; + showbrandimage: boolean; + brandimage: string; + buttonstyle: string; + }; + }; + autologout: boolean; + backend_configurable: boolean; + }; + auth: { + type: string | string[]; + anonymous_auth_enabled: boolean; + logout_url: string; + }; + idp: { + setting: typeof map; + }; +} diff --git a/src/plugins/dashboards_security/public/utils/auth_utils.ts b/src/plugins/dashboards_security/public/utils/auth_utils.ts new file mode 100644 index 000000000000..9ae972ce61b1 --- /dev/null +++ b/src/plugins/dashboards_security/public/utils/auth_utils.ts @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpStart } from 'opensearch-dashboards/public'; +import { API_ENDPOINT_AUTHTYPE } from '../../common'; +import { httpGet, httpPost } from './request_utils'; + +export async function validateCurrentPassword( + http: HttpStart, + userName: string, + currentPassword: string +): Promise { + await httpPost(http, `/auth/basicauth/opensearch/login`, { + username: userName, + password: currentPassword, + }); +} + +export async function logout(http: HttpStart, logoutUrl?: string): Promise { + const currentAuthType = (await fetchCurrentAuthType(http))?.currentAuthType; + const authType = currentAuthType.split('_')[0]; + const authIdent = currentAuthType.split('_')[1]; + + const logoutEndpoint = `/auth/${authType}/${authIdent}/logout`; + // console.log("Logout url:: ", logoutEndpoint); + await httpGet(http, logoutEndpoint); + + sessionStorage.clear(); + + const basePath = http.basePath.serverBasePath ? http.basePath.serverBasePath : '/'; + const nextUrl = encodeURIComponent(basePath); + window.location.href = + logoutUrl || `${http.basePath.serverBasePath}/app/login?nextUrl=${nextUrl}`; +} + +export async function fetchCurrentAuthType(http: HttpStart): Promise { + return await httpGet(http, API_ENDPOINT_AUTHTYPE); +} diff --git a/src/plugins/dashboards_security/public/utils/request_utils.ts b/src/plugins/dashboards_security/public/utils/request_utils.ts new file mode 100644 index 000000000000..6907ada6c5c3 --- /dev/null +++ b/src/plugins/dashboards_security/public/utils/request_utils.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpStart, HttpHandler } from 'opensearch-dashboards/public'; + +export async function request(requestFunc: HttpHandler, url: string, body?: object): Promise { + if (body) { + return (await requestFunc(url, { body: JSON.stringify(body) })) as T; + } + return (await requestFunc(url)) as T; +} + +export async function httpGet(http: HttpStart, url: string): Promise { + return await request(http.get, url); +} + +export async function httpPost(http: HttpStart, url: string, body?: object): Promise { + return await request(http.post, url, body); +} + +export async function httpDelete(http: HttpStart, url: string): Promise { + return await request(http.delete, url); +} diff --git a/src/plugins/dashboards_security/server/auth/auth_handler_factory.ts b/src/plugins/dashboards_security/server/auth/auth_handler_factory.ts new file mode 100644 index 000000000000..835e5a677c7a --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/auth_handler_factory.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IRouter, CoreSetup, Logger, SessionStorageFactory } from 'opensearch-dashboards/server'; +import { SecuritySessionCookie } from '../session/security_cookie'; +import { SecurityPluginConfigType } from '..'; +import { IAuthenticationType, IAuthHandlerConstructor } from './types/authentication_type'; +import { MultipleAuthentication } from './types'; + +async function createAuthentication( + ctor: IAuthHandlerConstructor, + config: SecurityPluginConfigType, + sessionStorageFactory: SessionStorageFactory, + router: IRouter, + coreSetup: CoreSetup, + logger: Logger +): Promise { + const authHandler = new ctor('', config, sessionStorageFactory, router, coreSetup, logger); + await authHandler.init(); + return authHandler; +} + +export async function getAuthenticationHandler( + router: IRouter, + config: SecurityPluginConfigType, + core: CoreSetup, + securitySessionStorageFactory: SessionStorageFactory, + logger: Logger +): Promise { + const authHandlerType: IAuthHandlerConstructor = MultipleAuthentication; + const auth: IAuthenticationType = await createAuthentication( + authHandlerType, + config, + securitySessionStorageFactory, + router, + core, + logger + ); + return auth; +} diff --git a/src/plugins/dashboards_security/server/auth/types/authentication_type.ts b/src/plugins/dashboards_security/server/auth/types/authentication_type.ts new file mode 100644 index 000000000000..dc3546460eaa --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/authentication_type.ts @@ -0,0 +1,150 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthenticationHandler, + SessionStorageFactory, + IRouter, + CoreSetup, + Logger, + AuthToolkit, + LifecycleResponseFactory, + OpenSearchDashboardsRequest, + IOpenSearchDashboardsResponse, + AuthResult, +} from 'opensearch-dashboards/server'; +import { SecurityPluginConfigType } from '../..'; +import { SecuritySessionCookie } from '../../session/security_cookie'; +import { authenticate } from '../../utils/auth_util'; + +export interface IAuthenticationType { + authType: string; + authHandler: AuthenticationHandler; + init: () => Promise; +} + +export type IAuthHandlerConstructor = new ( + authType: string, + config: SecurityPluginConfigType, + sessionStorageFactory: SessionStorageFactory, + router: IRouter, + coreSetup: CoreSetup, + logger: Logger +) => IAuthenticationType; + +export abstract class AuthenticationType implements IAuthenticationType { + protected static readonly ROUTES_TO_IGNORE: string[] = [ + '/api/core/capabilities', // FIXME: need to figureout how to bypass this API call + '/app/login', + ]; + protected static readonly REST_API_CALL_HEADER = 'osd-xsrf'; + + constructor( + public readonly authType: string, + protected readonly config: SecurityPluginConfigType, + protected readonly sessionStorageFactory: SessionStorageFactory, + protected readonly router: IRouter, + protected readonly coreSetup: CoreSetup, + protected readonly logger: Logger + ) {} + + public authHandler: AuthenticationHandler = async (request, response, toolkit) => { + // if browser request, auth logic is: + // 1. check if request includes auth header or paramter(e.g. jwt in url params) is present, if so, authenticate with auth header. + // 2. if auth header not present, check if auth cookie is present, if no cookie, send to authentication workflow + // 3. verify whether auth cookie is valid, if not valid, send to authentication workflow + // 4. if cookie is valid, pass to route handlers + const authHeaders = {}; + let cookie: SecuritySessionCookie | null | undefined; + let authInfo: any | undefined; + if (this.authNotRequired(request)) { + return toolkit.authenticated(); + } + + if (this.requestIncludesAuthInfo(request)) { + try { + const additonalAuthHeader = this.getAdditionalAuthHeader(request); + Object.assign(authHeaders, additonalAuthHeader); + authInfo = authenticate({ + username: 'admin', + password: 'admin', + }); + cookie = this.getCookie(request, authInfo); + this.sessionStorageFactory.asScoped(request).set(cookie); + } catch (error: any) { + return response.unauthorized({ + body: error.message, + }); + } + } else { + try { + cookie = await this.sessionStorageFactory.asScoped(request).get(); + } catch (error: any) { + this.logger.error(`Error parsing cookie: ${error.message}`); + cookie = undefined; + } + if (!cookie || !(await this.isValidCookie(cookie))) { + // clear cookie + this.sessionStorageFactory.asScoped(request).clear(); + + // send to auth workflow + return this.handleUnauthedRequest(request, response, toolkit); + } + + // extend session expiration time + if (this.config.session.keepalive) { + cookie!.expiryTime = Date.now() + this.config.session.ttl; + this.sessionStorageFactory.asScoped(request).set(cookie!); + } + // cookie is valid and build auth header + const authHeadersFromCookie = this.buildAuthHeaderFromCookie(cookie!); + Object.assign(authHeaders, authHeadersFromCookie); + const additonalAuthHeader = this.getAdditionalAuthHeader(request); + Object.assign(authHeaders, additonalAuthHeader); + } + + return toolkit.authenticated({ + requestHeaders: authHeaders, + }); + }; + + authNotRequired(request: OpenSearchDashboardsRequest): boolean { + const pathname = request.url.pathname; + if (!pathname) { + return false; + } + // allow requests to ignored routes + if (AuthenticationType.ROUTES_TO_IGNORE.includes(pathname!)) { + return true; + } + // allow requests to routes that doesn't require authentication + if (this.config.auth.unauthenticated_routes.indexOf(pathname!) > -1) { + // TODO: use opensearch-dashboards server user + return true; + } + return false; + } + + isPageRequest(request: OpenSearchDashboardsRequest) { + const path = request.url.pathname || '/'; + return path.startsWith('/app/') || path === '/' || path.startsWith('/goto/'); + } + + // abstract functions for concrete auth types to implement + public abstract requestIncludesAuthInfo(request: OpenSearchDashboardsRequest): boolean; + public abstract getAdditionalAuthHeader(request: OpenSearchDashboardsRequest): Promise; + public abstract getCookie( + request: OpenSearchDashboardsRequest, + authInfo: any + ): SecuritySessionCookie; + public abstract isValidCookie(cookie: SecuritySessionCookie): Promise; + protected abstract handleUnauthedRequest( + request: OpenSearchDashboardsRequest, + response: LifecycleResponseFactory, + toolkit: AuthToolkit + ): IOpenSearchDashboardsResponse | AuthResult; + public abstract buildAuthHeaderFromCookie(cookie: SecuritySessionCookie): any; + public abstract init(): Promise; +} diff --git a/src/plugins/dashboards_security/server/auth/types/basic/basic_auth.ts b/src/plugins/dashboards_security/server/auth/types/basic/basic_auth.ts new file mode 100644 index 000000000000..3069a1c634f8 --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/basic/basic_auth.ts @@ -0,0 +1,106 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { OpenSearchDashboardsResponse } from 'opensearch-dashboards/server/http/router'; +import { + CoreSetup, + SessionStorageFactory, + IRouter, + OpenSearchDashboardsRequest, + Logger, + LifecycleResponseFactory, + AuthToolkit, +} from 'opensearch-dashboards/server'; +import { SecurityPluginConfigType } from '../../..'; +import { SecuritySessionCookie } from '../../../session/security_cookie'; +import { BasicAuthRoutes } from './routes'; +import { AuthenticationType } from '../authentication_type'; +import { LOGIN_PAGE_URI } from '../../../../common'; +import { composeNextUrlQueryParam } from '../../../utils/next_url'; +import { AUTH_HEADER_NAME } from '../../../../common'; + +export class BasicAuthentication extends AuthenticationType { + constructor( + authType: string, + config: SecurityPluginConfigType, + sessionStorageFactory: SessionStorageFactory, + router: IRouter, + coreSetup: CoreSetup, + logger: Logger + ) { + super(authType, config, sessionStorageFactory, router, coreSetup, logger); + } + + public async init() { + const routes = new BasicAuthRoutes( + this.authType, + this.router, + this.config, + this.sessionStorageFactory, + this.coreSetup + ); + routes.setupRoutes(); + } + + requestIncludesAuthInfo( + request: OpenSearchDashboardsRequest + ): boolean { + return request.headers[AUTH_HEADER_NAME] ? true : false; + } + + async getAdditionalAuthHeader( + request: OpenSearchDashboardsRequest + ): Promise { + return {}; + } + + getCookie(request: OpenSearchDashboardsRequest, authInfo: any): SecuritySessionCookie { + return { + username: authInfo.user_name, + credentials: { + authHeaderValue: request.headers[AUTH_HEADER_NAME], + }, + authType: this.authType, + expiryTime: Date.now() + this.config.session.ttl, + }; + } + + async isValidCookie(cookie: SecuritySessionCookie): Promise { + return ( + cookie.authType === this.authType && + cookie.expiryTime && + cookie.username && + cookie.credentials?.authHeaderValue + ); + } + + handleUnauthedRequest( + request: OpenSearchDashboardsRequest, + response: LifecycleResponseFactory, + toolkit: AuthToolkit + ): OpenSearchDashboardsResponse { + if (this.isPageRequest(request)) { + const nextUrlParam = composeNextUrlQueryParam( + request, + this.coreSetup.http.basePath.serverBasePath + ); + const redirectLocation = `${this.coreSetup.http.basePath.serverBasePath}${LOGIN_PAGE_URI}?${nextUrlParam}`; + return response.redirected({ + headers: { + location: `${redirectLocation}`, + }, + }); + } else { + return response.unauthorized({ + body: `Authentication required`, + }); + } + } + + buildAuthHeaderFromCookie(cookie: SecuritySessionCookie): any { + const headers: any = {}; + Object.assign(headers, { authorization: cookie.credentials?.authHeaderValue }); + return headers; + } +} diff --git a/src/plugins/dashboards_security/server/auth/types/basic/routes.ts b/src/plugins/dashboards_security/server/auth/types/basic/routes.ts new file mode 100755 index 000000000000..85ea91572952 --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/basic/routes.ts @@ -0,0 +1,132 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 } from '@osd/config-schema'; +import { IRouter, SessionStorageFactory, CoreSetup } from 'opensearch-dashboards/server'; +import { + SecuritySessionCookie, + clearOldVersionCookieValue, +} from '../../../session/security_cookie'; +import { SecurityPluginConfigType } from '../../..'; +import { LOGIN_PAGE_URI } from '../../../../common'; +import { authenticate } from '../../../utils/auth_util'; + +export class BasicAuthRoutes { + private authProvider: string; + constructor( + private readonly authType: string, + private readonly router: IRouter, + private readonly config: SecurityPluginConfigType, + private readonly sessionStorageFactory: SessionStorageFactory, + private readonly coreSetup: CoreSetup + ) { + this.authProvider = this.authType.split('_')[1]; + } + + public setupRoutes() { + this.coreSetup.http.resources.register( + { + path: LOGIN_PAGE_URI, + validate: false, + options: { + authRequired: false, + }, + }, + async (context, request, response) => { + this.sessionStorageFactory.asScoped(request).clear(); + const clearOldVersionCookie = clearOldVersionCookieValue(this.config); + return response.renderAnonymousCoreApp({ + headers: { + 'set-cookie': clearOldVersionCookie, + }, + }); + } + ); + + // login using username and password + this.router.post( + { + path: `/auth/basicauth/${this.authProvider}/login`, + validate: { + body: schema.object({ + username: schema.string(), + password: schema.string(), + }), + }, + options: { + authRequired: false, + }, + }, + async (context, request, response) => { + try { + const user = authenticate({ + username: request.body.username, + password: request.body.password, + }); + + this.sessionStorageFactory.asScoped(request).clear(); + const encodedCredentials = Buffer.from( + `${request.body.username}:${request.body.password}` + ).toString('base64'); + const sessionStorage: SecuritySessionCookie = { + username: user?.username, + credentials: { + authHeaderValue: `Basic ${encodedCredentials}`, + }, + authType: this.authType, + expiryTime: Date.now() + this.config.session.ttl, + }; + + this.sessionStorageFactory.asScoped(request).set(sessionStorage); + await this.sessionStorageFactory.asScoped(request).get(); + return response.ok({ + body: { + username: user?.username, + }, + }); + } catch (error: any) { + // console.log(`Basic authentication failed: ${error}`); + return response.unauthorized({ + headers: { + 'www-authenticate': 'User not found', + }, + }); + } + } + ); + + // logout + this.router.get( + { + path: `/auth/basicauth/${this.authProvider}/logout`, + validate: false, + options: { + authRequired: false, + }, + }, + async (context, request, response) => { + this.sessionStorageFactory.asScoped(request).clear(); + return response.ok({ + body: {}, + }); + } + ); + } +} diff --git a/src/plugins/dashboards_security/server/auth/types/basic/user_bank.ts b/src/plugins/dashboards_security/server/auth/types/basic/user_bank.ts new file mode 100644 index 000000000000..4bb169faeeca --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/basic/user_bank.ts @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface UserSchema { + username: string; + password?: string; + authOption?: string; +} + +export const userProfiles = new Map([ + [ + 'admin', + { + username: 'admin', + password: 'admin', + authOption: 'basicauth_opensearch', + }, + ], + [ + 'aoguan', + { + username: 'aoguan', + password: 'admin', + authOption: 'basicauth_opensearch', + }, + ], + [ + 'aoguan@amazon.com', + { + username: 'aoguan@amazon.com', + authOption: 'oidc_okta', + }, + ], + [ + 'svc.opensearch.auth@gmail.com', + { + username: 'svc.opensearch.auth@gmail.com', + authOption: 'oidc_okta', + }, + ], +]); diff --git a/src/plugins/dashboards_security/server/auth/types/index.ts b/src/plugins/dashboards_security/server/auth/types/index.ts new file mode 100644 index 000000000000..c094badf6d08 --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { BasicAuthentication } from './basic/basic_auth'; +export { OpenIdAuthentication } from './openid/openid_auth'; +export { MultipleAuthentication } from './multiple/multi_auth'; diff --git a/src/plugins/dashboards_security/server/auth/types/multiple/multi_auth.ts b/src/plugins/dashboards_security/server/auth/types/multiple/multi_auth.ts new file mode 100644 index 000000000000..bad353669a7b --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/multiple/multi_auth.ts @@ -0,0 +1,165 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 { + CoreSetup, + SessionStorageFactory, + IRouter, + OpenSearchDashboardsRequest, + Logger, + LifecycleResponseFactory, + AuthToolkit, +} from 'opensearch-dashboards/server'; +import { OpenSearchDashboardsResponse } from '../../../../../../src/core/server/http/router'; +import { SecurityPluginConfigType } from '../../..'; +import { AuthenticationType } from '../authentication_type'; +import { ANONYMOUS_AUTH_LOGIN, AuthType, LOGIN_PAGE_URI } from '../../../../common'; +import { composeNextUrlQueryParam } from '../../../utils/next_url'; +import { SecuritySessionCookie } from '../../../session/security_cookie'; +import { BasicAuthentication, OpenIdAuthentication } from '../../types'; +import { getAuthTypes } from '../../../utils/common_util'; +import { MultiAuthRoutes } from './routes'; + +export class MultipleAuthentication extends AuthenticationType { + private authTypes: string[]; + private authHandlers: Map; + + constructor( + authType: string, + config: SecurityPluginConfigType, + sessionStorageFactory: SessionStorageFactory, + router: IRouter, + coreSetup: CoreSetup, + logger: Logger + ) { + super(authType, config, sessionStorageFactory, router, coreSetup, logger); + this.authTypes = getAuthTypes(this.config); + this.authHandlers = new Map(); + } + + public async init() { + const routes = new MultiAuthRoutes(this.router, this.sessionStorageFactory); + routes.setupRoutes(); + + for (const type of this.authTypes) { + const authOptions = type.split('_'); + switch (authOptions[0]) { + case AuthType.BASIC: { + const BasicAuth = new BasicAuthentication( + type, + this.config, + this.sessionStorageFactory, + this.router, + this.coreSetup, + this.logger + ); + await BasicAuth.init(); + this.authHandlers.set(type, BasicAuth); + break; + } + case AuthType.OIDC: { + const OidcAuth = new OpenIdAuthentication( + type, + this.config, + this.sessionStorageFactory, + this.router, + this.coreSetup, + this.logger + ); + await OidcAuth.init(); + this.authHandlers.set(type, OidcAuth); + break; + } + default: { + throw new Error(`Unsupported authentication type: ${authOptions[0]}`); + } + } + } + } + + // override functions inherited from AuthenticationType + requestIncludesAuthInfo( + request: OpenSearchDashboardsRequest + ): boolean { + for (const key of this.authHandlers.keys()) { + if (this.authHandlers.get(key)!.requestIncludesAuthInfo(request)) { + return true; + } + } + return false; + } + + async getAdditionalAuthHeader( + request: OpenSearchDashboardsRequest + ): Promise { + // To Do: refactor this method to improve the effiency to get cookie, get cookie from input parameter + const cookie = await this.sessionStorageFactory.asScoped(request).get(); + const reqAuthType = cookie?.authType?.toLowerCase(); + + if (reqAuthType && this.authHandlers.has(reqAuthType)) { + return this.authHandlers.get(reqAuthType)!.getAdditionalAuthHeader(request); + } else { + return {}; + } + } + + getCookie(request: OpenSearchDashboardsRequest, authInfo: any): SecuritySessionCookie { + return {}; + } + + async isValidCookie(cookie: SecuritySessionCookie): Promise { + const reqAuthType = cookie?.authType?.toLowerCase(); + if (reqAuthType && this.authHandlers.has(reqAuthType)) { + return this.authHandlers.get(reqAuthType)!.isValidCookie(cookie); + } else { + return false; + } + } + + handleUnauthedRequest( + request: OpenSearchDashboardsRequest, + response: LifecycleResponseFactory, + toolkit: AuthToolkit + ): OpenSearchDashboardsResponse { + if (this.isPageRequest(request)) { + const nextUrlParam = composeNextUrlQueryParam( + request, + this.coreSetup.http.basePath.serverBasePath + ); + + return response.redirected({ + headers: { + location: `${this.coreSetup.http.basePath.serverBasePath}${LOGIN_PAGE_URI}?${nextUrlParam}`, + }, + }); + } else { + return response.unauthorized(); + } + } + + buildAuthHeaderFromCookie(cookie: SecuritySessionCookie): any { + const reqAuthType = cookie?.authType?.toLowerCase(); + + if (reqAuthType && this.authHandlers.has(reqAuthType)) { + return this.authHandlers.get(reqAuthType)!.buildAuthHeaderFromCookie(cookie); + } else { + return {}; + } + } +} diff --git a/src/plugins/dashboards_security/server/auth/types/multiple/routes.ts b/src/plugins/dashboards_security/server/auth/types/multiple/routes.ts new file mode 100644 index 000000000000..62927c699a43 --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/multiple/routes.ts @@ -0,0 +1,52 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 } from 'opensearch-dashboards/server'; +import { SecuritySessionCookie } from '../../../session/security_cookie'; +import { API_ENDPOINT_AUTHTYPE } from '../../../../common'; + +export class MultiAuthRoutes { + constructor( + private readonly router: IRouter, + private readonly sessionStorageFactory: SessionStorageFactory + ) {} + + public setupRoutes() { + this.router.get( + { + path: API_ENDPOINT_AUTHTYPE, + validate: false, + }, + async (context, request, response) => { + const cookie = await this.sessionStorageFactory.asScoped(request).get(); + if (!cookie) { + return response.badRequest({ + body: 'Invalid cookie', + }); + } + return response.ok({ + body: { + currentAuthType: cookie?.authType, + }, + }); + } + ); + } +} diff --git a/src/plugins/dashboards_security/server/auth/types/openid/openid_auth.ts b/src/plugins/dashboards_security/server/auth/types/openid/openid_auth.ts new file mode 100644 index 000000000000..b9a5054510df --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/openid/openid_auth.ts @@ -0,0 +1,203 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 wreck from '@hapi/wreck'; +import { + Logger, + SessionStorageFactory, + CoreSetup, + IRouter, + OpenSearchDashboardsRequest, + LifecycleResponseFactory, + AuthToolkit, + IOpenSearchDashboardsResponse, +} from 'opensearch-dashboards/server'; +import { PeerCertificate } from 'tls'; +import { SecurityPluginConfigType } from '../../..'; +import { SecuritySessionCookie } from '../../../session/security_cookie'; +import { OpenIdAuthRoutes } from './routes'; +import { AuthenticationType } from '../authentication_type'; +import { composeNextUrlQueryParam } from '../../../utils/next_url'; +import { LOGIN_PAGE_URI } from '../../../../common'; +import { + callTokenEndpoint, + createWreckClient, + getExpirationDate, + getOIDCConfiguration, +} from '../../../utils/common_util'; + +export interface OpenIdAuthConfig { + authorizationEndpoint?: string; + tokenEndpoint?: string; + endSessionEndpoint?: string; + scope?: string; + issuer?: string; + authHeaderName?: string; +} + +export interface WreckHttpsOptions { + ca?: string | Buffer | Array; + checkServerIdentity?: (host: string, cert: PeerCertificate) => Error | undefined; +} + +export class OpenIdAuthentication extends AuthenticationType { + private openIdAuthConfig: OpenIdAuthConfig; + private wreckClient: typeof wreck; + private idpConfig: any; + constructor( + authType: string, + config: SecurityPluginConfigType, + sessionStorageFactory: SessionStorageFactory, + router: IRouter, + core: CoreSetup, + logger: Logger + ) { + super(authType, config, sessionStorageFactory, router, core, logger); + this.wreckClient = createWreckClient(this.config); + this.openIdAuthConfig = {}; + this.idpConfig = this.config.idp.setting.get(this.authType); + } + + public async init() { + try { + await getOIDCConfiguration( + this.authType, + this.config, + this.wreckClient, + this.openIdAuthConfig + ); + // console.log('this.openIdAuthConfig:: ', this.openIdAuthConfig); + + const routes = new OpenIdAuthRoutes( + this.authType, + this.router, + this.config, + this.sessionStorageFactory, + this.openIdAuthConfig, + this.coreSetup, + this.wreckClient + ); + routes.setupRoutes(); + } catch (error: any) { + this.logger.error(error); // TODO: log more info + throw new Error('Failed when trying to obtain the endpoints from your IdP'); + } + } + + requestIncludesAuthInfo(request: OpenSearchDashboardsRequest): boolean { + return request.headers.authorization ? true : false; + } + + async getAdditionalAuthHeader(request: OpenSearchDashboardsRequest): Promise { + return {}; + } + + getCookie(request: OpenSearchDashboardsRequest, authInfo: any): SecuritySessionCookie { + return { + username: authInfo.user_name, + credentials: { + authHeaderValue: request.headers.authorization, + }, + authType: this.authType, + expiryTime: Date.now() + this.config.session.ttl, + }; + } + + // TODO: Add token expiration check here + async isValidCookie(cookie: SecuritySessionCookie): Promise { + if ( + cookie.authType !== this.authType || + !cookie.username || + !cookie.expiryTime || + !cookie.credentials?.authHeaderValue || + !cookie.credentials?.expires_at + ) { + return false; + } + if (cookie.credentials?.expires_at > Date.now()) { + return true; + } + + // need to renew id token + if (cookie.credentials.refresh_token) { + try { + const query: any = { + grant_type: 'refresh_token', + client_id: this.idpConfig.client_id, + client_secret: this.idpConfig.client_secret, + refresh_token: cookie.credentials.refresh_token, + }; + const refreshTokenResponse = await callTokenEndpoint( + this.openIdAuthConfig.tokenEndpoint!, + query, + this.wreckClient + ); + + // if no id_token from refresh token call, maybe the Idp doesn't allow refresh id_token + if (refreshTokenResponse.idToken) { + cookie.credentials = { + authHeaderValue: `Bearer ${refreshTokenResponse.idToken}`, + refresh_token: refreshTokenResponse.refreshToken, + expires_at: getExpirationDate(refreshTokenResponse), // expiresIn is in second + }; + return true; + } else { + return false; + } + } catch (error: any) { + this.logger.error(error); + return false; + } + } else { + // no refresh token, and current token is expired + return false; + } + } + + handleUnauthedRequest( + request: OpenSearchDashboardsRequest, + response: LifecycleResponseFactory, + toolkit: AuthToolkit + ): IOpenSearchDashboardsResponse { + if (this.isPageRequest(request)) { + // nextUrl is a key value pair + const nextUrl = composeNextUrlQueryParam( + request, + this.coreSetup.http.basePath.serverBasePath + ); + return response.redirected({ + headers: { + location: `${this.coreSetup.http.basePath.serverBasePath}${LOGIN_PAGE_URI}?${nextUrl}`, + }, + }); + } else { + return response.unauthorized(); + } + } + + buildAuthHeaderFromCookie(cookie: SecuritySessionCookie): any { + const header: any = {}; + const authHeaderValue = cookie.credentials?.authHeaderValue; + if (authHeaderValue) { + header.authorization = authHeaderValue; + } + return header; + } +} diff --git a/src/plugins/dashboards_security/server/auth/types/openid/routes.ts b/src/plugins/dashboards_security/server/auth/types/openid/routes.ts new file mode 100644 index 000000000000..02c69e8a8891 --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/openid/routes.ts @@ -0,0 +1,240 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 } from '@osd/config-schema'; +import { randomString } from '@hapi/cryptiles'; +import { stringify } from 'querystring'; +import wreck from '@hapi/wreck'; +import { + IRouter, + SessionStorageFactory, + CoreSetup, + OpenSearchDashboardsResponseFactory, + OpenSearchDashboardsRequest, +} from 'opensearch-dashboards/server'; +import { SecuritySessionCookie } from '../../../session/security_cookie'; +import { SecurityPluginConfigType } from '../../..'; +import { OpenIdAuthConfig } from './openid_auth'; +import { validateNextUrl } from '../../../utils/next_url'; +import { AUTH_GRANT_TYPE, AUTH_RESPONSE_TYPE } from '../../../../common'; +import { authenticateWithToken } from '../../../utils/auth_util'; +import { + callTokenEndpoint, + composeLogoutUrl, + getBaseRedirectUrl, + getExpirationDate, +} from '../../../utils/common_util'; + +export class OpenIdAuthRoutes { + private static readonly NONCE_LENGTH: number = 22; + private authProvider: string; + private idpConfig: any; + + constructor( + private readonly authType: string, + private readonly router: IRouter, + private readonly config: SecurityPluginConfigType, + private readonly sessionStorageFactory: SessionStorageFactory, + private readonly openIdAuthConfig: OpenIdAuthConfig, + private readonly core: CoreSetup, + private readonly wreckClient: typeof wreck + ) { + this.authProvider = authType.split('_')[1]; + this.idpConfig = this.config.idp.setting.get(this.authType); + } + + private redirectToLogin( + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ) { + this.sessionStorageFactory.asScoped(request).clear(); + return response.redirected({ + headers: { + location: `${this.core.http.basePath.serverBasePath}/auth/oidc/${this.authProvider}/login`, + }, + }); + } + + public setupRoutes() { + this.router.get( + { + path: `/auth/oidc/${this.authProvider}/login`, + validate: { + query: schema.object( + { + code: schema.maybe(schema.string()), + nextUrl: schema.maybe( + schema.string({ + validate: validateNextUrl, + }) + ), + state: schema.maybe(schema.string()), + refresh: schema.maybe(schema.string()), + }, + { + unknowns: 'allow', + } + ), + }, + options: { + authRequired: false, + }, + }, + async (context, request, response) => { + if (!request.query.code) { + const nonce = randomString(OpenIdAuthRoutes.NONCE_LENGTH); + const query: any = { + client_id: this.idpConfig.client_id, + response_type: AUTH_RESPONSE_TYPE, + responseMode: 'query', + redirect_uri: `${getBaseRedirectUrl(this.config, this.core, request)}/auth/oidc/${ + this.authProvider + }/login`, + state: nonce, + scope: this.openIdAuthConfig.scope, + }; + + const queryString = stringify(query); + const location = `${this.openIdAuthConfig.authorizationEndpoint}?${queryString}`; + const cookie: SecuritySessionCookie = { + oidc: { + state: nonce, + nextUrl: request.query.nextUrl || '/', + }, + authType: this.authType, + }; + + this.sessionStorageFactory.asScoped(request).set(cookie); + return response.redirected({ + headers: { + location, + }, + }); + } + + // Authentication callback + // validate state first + let cookie; + try { + cookie = await this.sessionStorageFactory.asScoped(request).get(); + if ( + !cookie || + !cookie.oidc?.state || + cookie.oidc.state !== (request.query as any).state + ) { + // console.log('cookie got expired, need refresh'); + return this.redirectToLogin(request, response); + } + } catch (error) { + return this.redirectToLogin(request, response); + } + + try { + const nextUrl: string = cookie.oidc.nextUrl; + const clientId = this.idpConfig.client_id; + const clientSecret = this.idpConfig.client_secret; + const query: any = { + grant_type: AUTH_GRANT_TYPE, + code: request.query.code, + redirect_uri: `${getBaseRedirectUrl(this.config, this.core, request)}/auth/oidc/${ + this.authProvider + }/login`, + client_id: clientId, + client_secret: clientSecret, + }; + const tokenResponse = await callTokenEndpoint( + this.openIdAuthConfig.tokenEndpoint!, + query, + this.wreckClient + ); + // console.log('tokenResponse:: ', tokenResponse); + + const user = authenticateWithToken( + this.openIdAuthConfig.authHeaderName as string, + tokenResponse.idToken, + this.idpConfig, + this.openIdAuthConfig, + this.authType + ); + + // set to cookie + const sessionStorage: SecuritySessionCookie = { + username: user.username, + credentials: { + authHeaderValue: `Bearer ${tokenResponse.idToken}`, + expires_at: getExpirationDate(tokenResponse), + }, + authType: this.authType, + expiryTime: Date.now() + this.config.session.ttl, + }; + + if (this.idpConfig?.refresh_tokens && tokenResponse.refreshToken) { + Object.assign(sessionStorage.credentials, { + refresh_token: tokenResponse.refreshToken, + }); + } + + this.sessionStorageFactory.asScoped(request).set(sessionStorage); + return response.redirected({ + headers: { + location: nextUrl, + }, + }); + } catch (error: any) { + // console.log(`OpenId authentication failed: ${error}`); + if (error.toString().toLowerCase().includes('authentication exception')) { + return response.unauthorized(); + } else { + return this.redirectToLogin(request, response); + } + } + } + ); + + this.router.get( + { + path: `/auth/oidc/${this.authProvider}/logout`, + validate: false, + }, + async (context, request, response) => { + const cookie = await this.sessionStorageFactory.asScoped(request).get(); + this.sessionStorageFactory.asScoped(request).clear(); + + // authHeaderValue is the bearer header, e.g. "Bearer " + const token = cookie?.credentials.authHeaderValue.split(' ')[1]; // get auth token + const nextUrl = getBaseRedirectUrl(this.config, this.core, request); + const logoutQueryParams = { + post_logout_redirect_uri: `${nextUrl}`, + id_token_hint: token, + }; + const endSessionUrl = composeLogoutUrl( + this.idpConfig?.logout_url, + this.openIdAuthConfig.endSessionEndpoint, + logoutQueryParams + ); + return response.redirected({ + headers: { + location: endSessionUrl, + }, + }); + } + ); + } +} diff --git a/src/plugins/dashboards_security/server/auth/user.ts b/src/plugins/dashboards_security/server/auth/user.ts new file mode 100644 index 000000000000..b3f496105d05 --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/user.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface User { + username: string; + credentials?: string; +} diff --git a/src/plugins/dashboards_security/server/configuration/auth_config.ts b/src/plugins/dashboards_security/server/configuration/auth_config.ts new file mode 100644 index 000000000000..d26ef6ed8cb0 --- /dev/null +++ b/src/plugins/dashboards_security/server/configuration/auth_config.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const identityProviders = new Map([ + [ + 'basicauth.internal', + { + authOption: 'basic.internal', + }, + ], + [ + 'oidc.okta', + { + authOption: 'oidc.okta', + }, + ], +]); diff --git a/src/plugins/dashboards_security/server/index.ts b/src/plugins/dashboards_security/server/index.ts new file mode 100644 index 000000000000..9bc10ab32423 --- /dev/null +++ b/src/plugins/dashboards_security/server/index.ts @@ -0,0 +1,236 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * Copyright OpenSearch Contributors + * + * 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 '@osd/config-schema'; +import { PluginInitializerContext, PluginConfigDescriptor } from '../../../../src/core/server'; +import { SecurityPlugin } from './plugin'; + +const validateAuthType = (value: string[]) => { + const supportedAuthTypes = [ + '', + 'basic', + 'jwt', + 'openid', + 'saml', + 'proxy', + 'kerberos', + 'proxycache', + ]; + + value.forEach((authVal) => { + if (!supportedAuthTypes.includes(authVal.toLowerCase())) { + throw new Error( + `Unsupported authentication type: ${authVal}. Allowed auth.type are ${supportedAuthTypes}.` + ); + } + }); +}; + +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: [] }), + }), + clusterPermissions: schema.object({ + include: schema.arrayOf(schema.string(), { defaultValue: [] }), + }), + indexPermissions: schema.object({ + include: schema.arrayOf(schema.string(), { defaultValue: [] }), + }), + disabledTransportCategories: schema.object({ + exclude: schema.arrayOf(schema.string(), { defaultValue: [] }), + }), + disabledRestCategories: schema.object({ + exclude: schema.arrayOf(schema.string(), { defaultValue: [] }), + }), + cookie: schema.object({ + secure: schema.boolean({ defaultValue: false }), + 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.literal('Strict'), + schema.literal('Lax'), + schema.literal('None'), + schema.literal(false), + ], + { defaultValue: false } + ), + }), + session: schema.object({ + ttl: schema.number({ defaultValue: 60 * 60 * 1000 }), + keepalive: schema.boolean({ defaultValue: true }), + }), + auth: schema.object({ + type: schema.oneOf( + [ + schema.arrayOf(schema.string(), { + defaultValue: [''], + validate(value: string[]) { + if (!value || value.length === 0) { + return `Authentication type is not configured properly. At least one authentication type must be selected.`; + } + + if (value.length > 1) { + const includeBasicAuth = value.find((element) => { + return element.toLowerCase() === 'basicauth'; + }); + + if (!includeBasicAuth) { + return `Authentication type is not configured properly. basicauth is mandatory.`; + } + } + + validateAuthType(value); + }, + }), + schema.string({ + defaultValue: '', + validate(value: any) { + const valArray: string[] = []; + valArray.push(value); + validateAuthType(valArray); + }, + }), + ], + { defaultValue: '' } + ), + anonymous_auth_enabled: schema.boolean({ defaultValue: false }), + unauthenticated_routes: schema.arrayOf(schema.string(), { + defaultValue: ['/api/reporting/stats'], + }), + forbidden_usernames: schema.arrayOf(schema.string(), { defaultValue: [] }), + logout_url: schema.string({ defaultValue: '' }), + multiple_auth_enabled: schema.boolean({ defaultValue: false }), + }), + basicauth: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + unauthenticated_routes: schema.arrayOf(schema.string(), { defaultValue: [] }), + 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: 'Log in with provider' }), + buttonstyle: schema.string({ defaultValue: '' }), + }), + loadbalancer_url: schema.maybe(schema.string()), + login: schema.object({ + title: schema.string({ defaultValue: 'Log in to OpenSearch Dashboards' }), + subtitle: schema.string({ + defaultValue: + 'If you have forgotten your username or password, contact your system administrator.', + }), + showbrandimage: schema.boolean({ defaultValue: true }), + brandimage: schema.string({ defaultValue: '' }), // TODO: update brand image + buttonstyle: schema.string({ defaultValue: '' }), + }), + }), + configuration: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + idp: schema.object({ + setting: schema.mapOf(schema.string(), schema.any(), { + defaultValue: { basicauth_opensearch: { base_redirect_url: 'http://localhost:5601' } }, + }), + }), + accountinfo: 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 }), + refresh_tokens: schema.boolean({ defaultValue: true }), + trust_dynamic_headers: schema.boolean({ defaultValue: false }), + }) + ), + ui: schema.object({ + basicauth: schema.object({ + // the login config here is the same as old config `_security.basicauth.login` + // Since we are now rendering login page to browser app, so move these config to browser side. + login: schema.object({ + title: schema.string({ defaultValue: 'Log in to OpenSearch Dashboards' }), + subtitle: schema.string({ + defaultValue: + 'If you have forgotten your username or password, contact your system administrator.', + }), + showbrandimage: schema.boolean({ defaultValue: true }), + brandimage: schema.string({ defaultValue: '' }), + buttonstyle: schema.string({ defaultValue: '' }), + }), + }), + openid: schema.object({ + login: schema.object({ + buttonname: schema.string({ defaultValue: 'Log in with single sign-on' }), + showbrandimage: schema.boolean({ defaultValue: false }), + brandimage: schema.string({ defaultValue: '' }), + buttonstyle: schema.string({ defaultValue: '' }), + }), + }), + saml: schema.object({ + login: schema.object({ + buttonname: schema.string({ defaultValue: 'Log in with single sign-on' }), + showbrandimage: schema.boolean({ defaultValue: false }), + brandimage: schema.string({ defaultValue: '' }), + buttonstyle: schema.string({ defaultValue: '' }), + }), + }), + autologout: schema.boolean({ defaultValue: true }), + backend_configurable: schema.boolean({ defaultValue: true }), + }), +}); + +export type SecurityPluginConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + enabled: true, + auth: true, + ui: true, + readonly_mode: true, + idp: true, + }, + schema: configSchema, +}; + +export function plugin(initializerContext: PluginInitializerContext) { + return new SecurityPlugin(initializerContext); +} + +export { SecurityPluginSetup, SecurityPluginStart } from './types'; diff --git a/src/plugins/dashboards_security/server/plugin.ts b/src/plugins/dashboards_security/server/plugin.ts new file mode 100644 index 000000000000..70fed2b3d68f --- /dev/null +++ b/src/plugins/dashboards_security/server/plugin.ts @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, + SessionStorageFactory, +} from 'opensearch-dashboards/server'; +import { first } from 'rxjs/operators'; +import { SecurityPluginSetup, SecurityPluginStart } from './types'; +import { SecurityPluginConfigType } from '.'; +import { SecuritySessionCookie, getSecurityCookieOptions } from './session/security_cookie'; +import { getAuthenticationHandler } from './auth/auth_handler_factory'; +import { IAuthenticationType } from './auth/types/authentication_type'; + + +export class SecurityPlugin implements Plugin { + private readonly logger: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public async setup(core: CoreSetup) { + const config$ = this.initializerContext.config.create(); + const config: SecurityPluginConfigType = await config$.pipe(first()).toPromise(); + + const router = core.http.createRouter(); + + const securitySessionStorageFactory: SessionStorageFactory = await core.http.createCookieSessionStorageFactory< + SecuritySessionCookie + >(getSecurityCookieOptions(config)); + + // setup auth + const auth: IAuthenticationType = await getAuthenticationHandler( + router, + config, + core, + securitySessionStorageFactory, + this.logger + ); + core.http.registerAuth(auth.authHandler); + + return { + config$, + }; + } + + // TODO: add more logs + public async start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/dashboards_security/server/session/security_cookie.ts b/src/plugins/dashboards_security/server/session/security_cookie.ts new file mode 100644 index 000000000000..63bca5e6d83e --- /dev/null +++ b/src/plugins/dashboards_security/server/session/security_cookie.ts @@ -0,0 +1,70 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SessionStorageCookieOptions } from 'opensearch-dashboards/server'; +import { SecurityPluginConfigType } from '..'; + +export interface SecuritySessionCookie { + // security_authentication + username?: string; + credentials?: any; + authType?: string; + assignAuthHeader?: boolean; + isAnonymousAuth?: boolean; + expiryTime?: number; + additionalAuthHeaders?: any; + + // for oidc auth workflow + oidc?: any; + + // for Saml auth workflow + saml?: { + requestId?: string; + nextUrl?: string; + redirectHash?: boolean; + }; +} + +export function getSecurityCookieOptions( + config: SecurityPluginConfigType +): SessionStorageCookieOptions { + return { + name: config.cookie.name, + encryptionKey: config.cookie.password, + validate: (sessionStorage: SecuritySessionCookie | SecuritySessionCookie[]) => { + sessionStorage = sessionStorage as SecuritySessionCookie; + if (sessionStorage === undefined) { + return { isValid: false, path: '/' }; + } + + // TODO: with setting redirect attributes to support OIDC and SAML, + // we need to do additonal cookie validatin in AuthenticationHandlers. + // if SAML fields present + if (sessionStorage.saml && sessionStorage.saml.requestId && sessionStorage.saml.nextUrl) { + return { isValid: true, path: '/' }; + } + + // if OIDC fields present + if (sessionStorage.oidc) { + return { isValid: true, path: '/' }; + } + + if (sessionStorage.expiryTime === undefined || sessionStorage.expiryTime < Date.now()) { + return { isValid: false, path: '/' }; + } + return { isValid: true, path: '/' }; + }, + isSecure: config.cookie.secure, + sameSite: config.cookie.isSameSite || undefined, + }; +} + +export function clearOldVersionCookieValue(config: SecurityPluginConfigType): string { + if (config.cookie.secure) { + return 'security_authentication=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; HttpOnly; Path=/'; + } else { + return 'security_authentication=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/'; + } +} diff --git a/src/plugins/dashboards_security/server/types.ts b/src/plugins/dashboards_security/server/types.ts new file mode 100644 index 000000000000..09f521219923 --- /dev/null +++ b/src/plugins/dashboards_security/server/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SecurityPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SecurityPluginStart {} diff --git a/src/plugins/dashboards_security/server/utils/auth_util.ts b/src/plugins/dashboards_security/server/utils/auth_util.ts new file mode 100644 index 000000000000..3504ea8c5281 --- /dev/null +++ b/src/plugins/dashboards_security/server/utils/auth_util.ts @@ -0,0 +1,97 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { User } from '../auth/user'; +import { userProfiles } from '../auth/types/basic/user_bank'; +import { OpenIdAuthConfig } from '../auth/types/openid/openid_auth'; + +export const authenticate = (authBody: any): User | null => { + const user = userProfiles.get(authBody.username); + try { + if (user !== undefined && user.password === authBody.password) { + return { + username: authBody.username, + credentials: authBody.password, + }; + } else { + throw new Error('authentication exception:: User not found'); + } + } catch (error: any) { + throw new Error(error.message); + } +}; + +export const authenticateWithToken = ( + authzHeader: string, + idToken: string | undefined, + idpConfig: any, + openIdAuthConfig: OpenIdAuthConfig, + authType: string +): User => { + try { + // If IdToke = null => not authenticated + // Else: if token payload.email does not exist => insert entry + // else: get the user info from identity storage + + const credentials: any = { + authzHeader, + idToken, + }; + + if (!idToken) { + throw new Error('authentication exception'); + } else { + const decodedIdToken = decodeIdToken(idToken); + if (!validateIdToken(decodedIdToken, idpConfig, openIdAuthConfig)) { + throw new Error('authentication exception:: Invalid ID Token'); + } + + const username = decodedIdToken.email; + const user = userProfiles.get(username); + + if (user) { + return { + username: user.username, + credentials, + }; + } else { + // insert into user_bank, need implementation + userProfiles.set(username, { username, password: '', authOption: authType }); + // console.log("userProfile:: ", userProfiles); + return { + username, + credentials, + }; + } + } + } catch (error: any) { + throw new Error(error.message); + } +}; + +const decodeIdToken = (token: string): any => { + const parts = token.toString().split('.'); + if (parts.length !== 3) { + throw new Error('authentication exception:: Invalid token'); + } + const claim = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + + return claim; +}; + +const validateIdToken = ( + idToken: any, + idpConfig: any, + openIdAuthConfig: OpenIdAuthConfig +): boolean => { + if ( + idToken.aud !== idpConfig.client_id || + idToken.iss !== openIdAuthConfig.issuer || + idToken.exp > Date.now() + ) { + return false; + } + return true; +}; diff --git a/src/plugins/dashboards_security/server/utils/common_util.ts b/src/plugins/dashboards_security/server/utils/common_util.ts new file mode 100644 index 000000000000..e0ff18b591a0 --- /dev/null +++ b/src/plugins/dashboards_security/server/utils/common_util.ts @@ -0,0 +1,255 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import wreck from '@hapi/wreck'; +import { PeerCertificate } from 'tls'; +import * as fs from 'fs'; +import HTTP from 'http'; +import HTTPS from 'https'; +import { parse, stringify } from 'querystring'; +import { CoreSetup } from 'opensearch-dashboards/server'; +import { OpenSearchDashboardsRequest } from 'opensearch-dashboards/server'; +import { SecurityPluginConfigType } from '..'; +import { OpenIdAuthConfig, WreckHttpsOptions } from '../auth/types/openid/openid_auth'; + +export const getAuthTypes = (config: SecurityPluginConfigType): string[] => { + const authTypes: string[] = []; + const identityProviders = config.idp.setting; + + for (const authType of identityProviders.keys()) { + if (authType) { + authTypes.push(authType); + } else { + // Error handling for auth-type not properly defined. + } + } + // console.log("authTypes:: ", authTypes); + return authTypes; +}; + +export const createWreckClient = (config: SecurityPluginConfigType): typeof wreck => { + const wreckHttpsOption: WreckHttpsOptions = {}; + + if (config.openid?.root_ca) { + wreckHttpsOption.ca = [fs.readFileSync(config.openid.root_ca)]; + } + if (config.openid?.verify_hostnames === false) { + // this.logger.debug(`openId auth 'verify_hostnames' option is off.`); + wreckHttpsOption.checkServerIdentity = (host: string, cert: PeerCertificate) => { + return undefined; + }; + } + if (Object.keys(wreckHttpsOption).length > 0) { + return wreck.defaults({ + agents: { + http: new HTTP.Agent(), + https: new HTTPS.Agent(wreckHttpsOption), + httpsAllowUnauthorized: new HTTPS.Agent({ + rejectUnauthorized: false, + }), + }, + }); + } else { + return wreck; + } +}; + +// OIDC Authentication Helper Methods +export const getOIDCConfiguration = async ( + authType: string, + config: SecurityPluginConfigType, + wreckClient: typeof wreck, + openIdAuthConfig: OpenIdAuthConfig +) => { + const idpSetting = config.idp.setting.get(authType); + const authHeaderName = config.openid?.header || ''; + openIdAuthConfig.authHeaderName = authHeaderName; + + let scope = idpSetting.scope; + if (scope.indexOf('openid') < 0) { + scope = `openid ${scope}`; + } + openIdAuthConfig.scope = scope; + const openIdConnectUrl = idpSetting.connect_url; + const response = await wreckClient.get(openIdConnectUrl); + const payload = JSON.parse(response.payload as string); + + openIdAuthConfig.authorizationEndpoint = payload.authorization_endpoint; + openIdAuthConfig.tokenEndpoint = payload.token_endpoint; + openIdAuthConfig.endSessionEndpoint = payload.end_session_endpoint || undefined; + openIdAuthConfig.issuer = payload.issuer; +}; + +export function parseTokenResponse(payload: Buffer) { + const payloadString = payload.toString(); + if (payloadString.trim()[0] === '{') { + try { + return JSON.parse(payloadString); + } catch (error) { + throw Error(`Invalid JSON payload: ${error}`); + } + } + return parse(payloadString); +} + +export function getRootUrl( + config: SecurityPluginConfigType, + core: CoreSetup, + request: OpenSearchDashboardsRequest +): string { + const host = core.http.getServerInfo().hostname; + const port = core.http.getServerInfo().port; + let protocol = core.http.getServerInfo().protocol; + let httpHost = `${host}:${port}`; + + if (config.openid?.trust_dynamic_headers) { + const xForwardedHost = (request.headers['x-forwarded-host'] as string) || undefined; + const xForwardedProto = (request.headers['x-forwarded-proto'] as string) || undefined; + if (xForwardedHost) { + httpHost = xForwardedHost; + } + if (xForwardedProto) { + protocol = xForwardedProto; + } + } + + return `${protocol}://${httpHost}`; +} + +export function getBaseRedirectUrl( + config: SecurityPluginConfigType, + core: CoreSetup, + request: OpenSearchDashboardsRequest +): string { + if (config.openid?.base_redirect_url) { + const baseRedirectUrl = config.openid.base_redirect_url; + return baseRedirectUrl.endsWith('/') ? baseRedirectUrl.slice(0, -1) : baseRedirectUrl; + } + + const rootUrl = getRootUrl(config, core, request); + if (core.http.basePath.serverBasePath) { + return `${rootUrl}${core.http.basePath.serverBasePath}`; + } + return rootUrl; +} + +export async function callTokenEndpoint( + tokenEndpoint: string, + query: any, + wreckClient: typeof wreck +): Promise { + const tokenResponse = await wreckClient.post(tokenEndpoint, { + payload: stringify(query), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + if ( + !tokenResponse.res?.statusCode || + tokenResponse.res.statusCode < 200 || + tokenResponse.res.statusCode > 299 + ) { + throw new Error( + `Failed calling token endpoint: ${tokenResponse.res.statusCode} ${tokenResponse.res.statusMessage}` + ); + } + const tokenPayload: any = parseTokenResponse(tokenResponse.payload as Buffer); + + return { + idToken: tokenPayload.id_token, + accessToken: tokenPayload.access_token, + refreshToken: tokenPayload.refresh_token, + expiresIn: tokenPayload.expires_in, + }; +} + +export function composeLogoutUrl( + customLogoutUrl: string | undefined, + idpEndsessionEndpoint: string | undefined, + additionalQueryParams: any +) { + const logoutEndpont = customLogoutUrl || idpEndsessionEndpoint; + const logoutUrl = new URL(logoutEndpont!); + Object.keys(additionalQueryParams).forEach((key) => { + logoutUrl.searchParams.append(key, additionalQueryParams[key] as string); + }); + return logoutUrl.toString(); +} + +export interface TokenResponse { + idToken?: string; + accessToken?: string; + refreshToken?: string; + expiresIn?: number; +} + +export function getExpirationDate(tokenResponse: TokenResponse | undefined) { + if (!tokenResponse) { + throw new Error('Invalid token'); + } else if (tokenResponse.idToken) { + const idToken = tokenResponse.idToken; + const parts = idToken.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid token'); + } + const claim = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + return claim.exp * 1000; + } else { + return Date.now() + tokenResponse.expiresIn! * 1000; + } +} + +/* +export async function callTokenEndpoint( + request: OpenSearchDashboardsRequest, + core: CoreSetup, + cookie: SecuritySessionCookie, + config: SecurityPluginConfigType, + openIdAuthConfig: OpenIdAuthConfig, + wreckClient: typeof wreck +): Promise { + const nextUrl: string = cookie.oidc.nextUrl; + const clientId = config.openid?.client_id; + const clientSecret = config.openid?.client_secret; + const query: any = { + grant_type: AUTH_GRANT_TYPE, + code: request.query.code, + redirect_uri: `${getBaseRedirectUrl( + config, + core, + request + )}${OPENID_AUTH_LOGIN}`, + client_id: clientId, + client_secret: clientSecret, + }; + console.log("callTokenEndpoint::nextUrl:: ", nextUrl); + const tokenEndpoint = openIdAuthConfig.tokenEndpoint!; + const tokenResponse = await wreckClient.post(tokenEndpoint, { + payload: stringify(query), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + if ( + !tokenResponse.res?.statusCode || + tokenResponse.res.statusCode < 200 || + tokenResponse.res.statusCode > 299 + ) { + throw new Error( + `Failed calling token endpoint: ${tokenResponse.res.statusCode} ${tokenResponse.res.statusMessage}` + ); + } + const tokenPayload: any = parseTokenResponse(tokenResponse.payload as Buffer); + console.log("tokenPayload:: ", tokenPayload); + return { + idToken: tokenPayload.id_token, + accessToken: tokenPayload.access_token, + refreshToken: tokenPayload.refresh_token, + expiresIn: tokenPayload.expires_in, + }; +} +*/ diff --git a/src/plugins/dashboards_security/server/utils/next_url.ts b/src/plugins/dashboards_security/server/utils/next_url.ts new file mode 100644 index 000000000000..05e7b45a8d5c --- /dev/null +++ b/src/plugins/dashboards_security/server/utils/next_url.ts @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parse } from 'url'; +import { ParsedUrlQuery } from 'querystring'; +import { OpenSearchDashboardsRequest } from 'opensearch-dashboards/server'; +import { encodeUriQuery } from '../../../opensearch_dashboards_utils/common/url/encode_uri_query'; + +export function composeNextUrlQueryParam( + request: OpenSearchDashboardsRequest, + basePath: string +): string { + try { + const currentUrl = request.url.toString(); + const parsedUrl = parse(currentUrl, true); + const nextUrl = parsedUrl?.path; + + if (!!nextUrl && nextUrl !== '/') { + return `nextUrl=${encodeUriQuery(basePath + nextUrl)}`; + } + } catch (error) { + /* Ignore errors from parsing */ + } + return ''; +} + +export interface ParsedUrlQueryParams extends ParsedUrlQuery { + nextUrl: string; +} + +export const INVALID_NEXT_URL_PARAMETER_MESSAGE = 'Invalid nextUrl parameter.'; + +/** + * We require the nextUrl parameter to be an relative url. + * + * Here we leverage the normalizeUrl function. If the library can parse the url + * parameter, which means it is an absolute url, then we reject it. Otherwise, the + * library cannot parse the url, which means it is not an absolute url, we let to + * go through. + * Note: url has been decoded by OpenSearchDashboards. + * + * @param url url string. + * @returns error message if nextUrl is invalid, otherwise void. + */ +export const validateNextUrl = (url: string | undefined): string | void => { + if (url) { + const path = url.split('?')[0]; + if ( + !path.startsWith('/') || + path.startsWith('//') || + path.includes('\\') || + path.includes('@') + ) { + return INVALID_NEXT_URL_PARAMETER_MESSAGE; + } + } +}; From 0a9b37ef7baf74e0a88a608f3756412e70cbc2a1 Mon Sep 17 00:00:00 2001 From: Aozixuan Priscilla Guan Date: Mon, 6 Feb 2023 16:22:48 -0600 Subject: [PATCH 4/6] Fix style Signed-off-by: Aozixuan Priscilla Guan --- src/plugins/dashboards_security/server/plugin.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugins/dashboards_security/server/plugin.ts b/src/plugins/dashboards_security/server/plugin.ts index 70fed2b3d68f..fdec8438097d 100644 --- a/src/plugins/dashboards_security/server/plugin.ts +++ b/src/plugins/dashboards_security/server/plugin.ts @@ -18,7 +18,6 @@ import { SecuritySessionCookie, getSecurityCookieOptions } from './session/secur import { getAuthenticationHandler } from './auth/auth_handler_factory'; import { IAuthenticationType } from './auth/types/authentication_type'; - export class SecurityPlugin implements Plugin { private readonly logger: Logger; From 92800357e8f4f358a3171820be7b27d272a87e94 Mon Sep 17 00:00:00 2001 From: Chang Liu Date: Mon, 6 Feb 2023 15:48:51 -0800 Subject: [PATCH 5/6] SAML PoC --- config/opensearch_dashboards.yml | 52 ++++ .../dashboards_security/common/index.ts | 4 + src/plugins/dashboards_security/package.json | 41 +++ .../server/auth/types/saml/routes.ts | 290 ++++++++++++++++++ .../server/auth/types/saml/saml_auth.ts | 136 ++++++++ .../server/auth/types/saml/utils/AuthToken.ts | 25 ++ .../auth/types/saml/utils/SAMLResponse.ts | 45 +++ .../backend/opensearch_security_client.ts | 235 ++++++++++++++ 8 files changed, 828 insertions(+) create mode 100644 src/plugins/dashboards_security/package.json create mode 100644 src/plugins/dashboards_security/server/auth/types/saml/routes.ts create mode 100644 src/plugins/dashboards_security/server/auth/types/saml/saml_auth.ts create mode 100644 src/plugins/dashboards_security/server/auth/types/saml/utils/AuthToken.ts create mode 100644 src/plugins/dashboards_security/server/auth/types/saml/utils/SAMLResponse.ts create mode 100755 src/plugins/dashboards_security/server/backend/opensearch_security_client.ts diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 4d81b0b3be69..0099d86d84f2 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -232,3 +232,55 @@ #data_source.encryption.wrappingKeyName: 'changeme' #data_source.encryption.wrappingKeyNamespace: 'changeme' #data_source.encryption.wrappingKey: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] + + +# timelion.ui.enabled: true +# server.name: opensearch-dashboards +# server.host: "0.0.0.0" +# opensearch.hosts: http://localhost:9200 +# opensearch.ssl.verificationMode: none +# opensearch.username: kibanaserver +# opensearch.password: kibanaserver +# opensearch.requestHeadersWhitelist: ["securitytenant","Authorization"] +# # dashboards_security.multitenancy.enabled: true +# # dashboards_security.multitenancy.tenants.preferred: ["Private", "Global"] +# dashboards_security.readonly_mode.roles: ["kibana_read_only"] +# # dashboards_security.auth.type: "saml" +# server.xsrf.whitelist: [/_plugins/_security/saml/acs,/_opendistro/_security/saml/acs,/_plugins/_security/saml/acs/idpinitiated,/_opendistro/_security/saml/acs/idpinitiated,/_plugins/_security/saml/logout,/_opendistro/_security/saml/logout] + + + + +server.host: 0.0.0.0 +server.port: 5601 +opensearch.hosts: ["http://localhost:9200"] +opensearch.ssl.verificationMode: none +opensearch.username: "kibanaserver" +opensearch.password: "kibanaserver" +opensearch.requestHeadersWhitelist: [ authorization,securitytenant ] + +dashboards_security.idp.setting: { + "basicauth_opensearch": + { + "base_redirect_url": "http://localhost:5601"}, + "oidc_okta": + { + "base_redirect_url": "http://localhost:5601", + "logout_url": "http://localhost:5601/app/login", + "connect_url": "https://dev-16628832.okta.com/.well-known/openid-configuration", + "client_id": "0oa566po99gotj46m5d7", + "client_secret": "4Gy9_NxFS2Xf97t4GRzkoRlyRAsApRwFcM6Zx9WB", + "scope": "openid profile email", + "verify_hostnames": "false", + "refresh_tokens": "false"}, + "oidc_google": + { + "base_redirect_url": "http://localhost:5601", + "logout_url": "http://localhost:5601/app/login", + "connect_url": "https://accounts.google.com/.well-known/openid-configuration", + "client_id": "177403260062-qsvknolof1u4qfmv3qtjti45eps7k3qs.apps.googleusercontent.com", + "client_secret": "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", + "scope": "openid profile email", + "verify_hostnames": "false", + "refresh_tokens": "false"}, +} \ No newline at end of file diff --git a/src/plugins/dashboards_security/common/index.ts b/src/plugins/dashboards_security/common/index.ts index c544cb202650..37cedca9cbb1 100644 --- a/src/plugins/dashboards_security/common/index.ts +++ b/src/plugins/dashboards_security/common/index.ts @@ -15,6 +15,8 @@ export const API_ENDPOINT_AUTHTYPE = API_PREFIX + '/auth/type'; export const LOGIN_PAGE_URI = '/app/' + APP_ID_LOGIN; export const API_AUTH_LOGIN = '/auth/login'; export const API_AUTH_LOGOUT = '/auth/logout'; +export const OPENID_AUTH_LOGIN = '/auth/openid/login'; +export const SAML_AUTH_LOGIN = '/auth/saml/login'; export const ANONYMOUS_AUTH_LOGIN = '/auth/anonymous'; export const SAML_AUTH_LOGIN_WITH_FRAGMENT = '/auth/saml/captureUrlFragment?nextUrl=%2F'; @@ -26,6 +28,8 @@ export const AUTH_HEADER_NAME = 'authorization'; export const AUTH_GRANT_TYPE = 'authorization_code'; export const AUTH_RESPONSE_TYPE = 'code'; +export const jwtKey = "6aff3042-1327-4f3d-82f0-40a157ac4464" + export enum AuthType { BASIC = 'basicauth', OIDC = 'oidc', diff --git a/src/plugins/dashboards_security/package.json b/src/plugins/dashboards_security/package.json new file mode 100644 index 000000000000..d5ebb1f8c625 --- /dev/null +++ b/src/plugins/dashboards_security/package.json @@ -0,0 +1,41 @@ +{ + "name": "dashboards-security", + "version": "3.0.0.0", + "main": "target/plugins/dashboards-security", + "opensearchDashboards": { + "version": "3.0.0", + "templateVersion": "3.0.0" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/opensearch-project/security-dashboards-plugin", + "scripts": { + "plugin-helpers": "node ../../scripts/plugin_helpers", + "osd": "node ../../scripts/osd", + "opensearch": "node ../../scripts/opensearch", + "build": "yarn plugin-helpers build && node build_tools/rename_zip.js", + "start": "node ../../scripts/opensearch-dashboards --dev", + "lint:es": "node ../../scripts/eslint", + "lint:style": "node ../../scripts/stylelint", + "lint": "yarn run lint:es && yarn run lint:style", + "pretest:jest_server": "node ./test/jest_integration/runIdpServer.js &", + "test:jest_server": "node ./test/run_jest_tests.js --config ./test/jest.config.server.js", + "test:jest_ui": "node ./test/run_jest_tests.js --config ./test/jest.config.ui.js" + }, + "devDependencies": { + "@elastic/eslint-import-resolver-kibana": "link:../../packages/osd-eslint-import-resolver-opensearch-dashboards", + "@testing-library/react-hooks": "^7.0.2", + "@types/hapi__wreck": "^15.0.1", + "gulp-rename": "2.0.0", + "saml-idp": "^1.2.1", + "selenium-webdriver": "^4.0.0-alpha.7", + "selfsigned": "^2.0.1", + "typescript": "4.0.2", + "saml-encoder-decoder-js": "1.0.1", + "fast-xml-parser": "4.0.15" + }, + "dependencies": { + "@hapi/cryptiles": "5.0.0", + "@hapi/wreck": "^17.1.0", + "html-entities": "1.3.1" + } +} diff --git a/src/plugins/dashboards_security/server/auth/types/saml/routes.ts b/src/plugins/dashboards_security/server/auth/types/saml/routes.ts new file mode 100644 index 000000000000..10b083b411f3 --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/saml/routes.ts @@ -0,0 +1,290 @@ +/* + * Copyright OpenSearch Contributors + * + * 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 } from '@osd/config-schema'; +import { + IRouter, + SessionStorageFactory, + OpenSearchDashboardsRequest, +} from '../../../../../../../src/core/server'; +import { SecuritySessionCookie } from '../../../session/security_cookie'; +import { SecurityPluginConfigType } from '../../..'; +import { SecurityClient } from '../../../backend/opensearch_security_client'; +import { CoreSetup } from '../../../../../../../src/core/server'; +import { validateNextUrl } from '../../../utils/next_url'; +import { AuthType, SAML_AUTH_LOGIN, SAML_AUTH_LOGOUT } from '../../../../common'; +import { compileSchema } from 'ajv/dist/compile'; +import { XMLParser } from "fast-xml-parser"; +import { AuthToken } from './utils/AuthToken'; + +export class SamlAuthRoutes { + constructor( + private readonly router: IRouter, + // @ts-ignore: unused variable + private readonly config: SecurityPluginConfigType, + private readonly sessionStorageFactory: SessionStorageFactory, + private readonly securityClient: SecurityClient, + private readonly coreSetup: CoreSetup + ) {} + + public setupRoutes() { + this.router.get( + { + path: SAML_AUTH_LOGIN, + validate: { + query: schema.object({ + nextUrl: schema.maybe( + schema.string({ + validate: validateNextUrl, + }) + ), + }), + }, + options: { + authRequired: false, + }, + }, + async (context, request, response) => { + if (request.auth.isAuthenticated) { + return response.redirected({ + headers: { + location: `${this.coreSetup.http.basePath.serverBasePath}/app/opensearch-dashboards`, + }, + }); + } + + try { + const samlHeader = await this.securityClient.getSamlHeader(request); + // const { nextUrl = '/' } = request.query; + const cookie: SecuritySessionCookie = { + saml: { + nextUrl: request.query.nextUrl, + requestId: samlHeader.requestId, + }, + }; + this.sessionStorageFactory.asScoped(request).set(cookie); + return response.redirected({ + headers: { + location: samlHeader.location, + }, + }); + } catch (error) { + context.security_plugin.logger.error(`Failed to get saml header: ${error}`); + return response.internalError(); // TODO: redirect to error page? + } + } + ); + + this.router.post( + { + path: `/_opendistro/_security/saml/acs`, + validate: { + body: schema.any(), + }, + options: { + authRequired: false, + }, + }, + async (context, request, response) => { + let requestId: string = ''; + let nextUrl: string = '/'; + try { + const cookie = await this.sessionStorageFactory.asScoped(request).get(); + if (cookie) { + requestId = cookie.saml?.requestId || ''; + nextUrl = + cookie.saml?.nextUrl || + `${this.coreSetup.http.basePath.serverBasePath}/app/opensearch-dashboards`; + } + if (!requestId) { + return response.badRequest({ + body: 'Invalid requestId', + }); + } + } catch (error) { + context.security_plugin.logger.error(`Failed to parse cookie: ${error}`); + return response.badRequest(); + } + + try { + const SAML = require("saml-encoder-decoder-js"); + const xmlParser = new XMLParser(); + const samlResponse = request.body.SAMLResponse; + // TODO: + // - Validate SAML Response + // - Set the SAML Response expiry in cookie + // - Consider how identiy info updates in IDP are synced with SP(Just in time/Real Time updates) + SAML.decodeSamlPost(samlResponse, function(err: string | undefined, xml: any) { + if (err) { + throw new Error(err); + } + const jsonObj = xmlParser.parse(xml); + const username = jsonObj["samlp:Response"]["saml:Assertion"]["saml:Subject"]["saml:NameID"]; + }); + + + // const credentials = await this.securityClient.authToken( + // requestId, + // request.body.SAMLResponse, + // undefined + // ); + // const user = await this.securityClient.authenticateWithHeader( + // request, + // 'authorization', + // credentials.authorization + // ); + + let expiryTime = Date.now() + this.config.session.ttl; + // const [headerEncoded, payloadEncoded, signature] = credentials.authorization.split('.'); + // if (!payloadEncoded) { + // context.security_plugin.logger.error('JWT token payload not found'); + // } + // const tokenPayload = JSON.parse(Buffer.from(payloadEncoded, 'base64').toString()); + + // if (tokenPayload.exp) { + // expiryTime = parseInt(tokenPayload.exp, 10) * 1000; + // } + // const cookie: SecuritySessionCookie = { + // username: user.username, + // credentials: { + // authHeaderValue: credentials.authorization, + // }, + // authType: AuthType.SAML, // TODO: create constant + // expiryTime, + // }; + // console.log("######cookie"); + // console.log(cookie); + + const authToken = new AuthToken(samlResponse); + const credentials = authToken.token; + console.log("######### credentials"); + console.log(credentials); + + // const cookie: SecuritySessionCookie = { + // username: "cgliu@amazon.com", + // credentials: { + // authHeaderValue: "bearer eyJhbGciOiJIUzUxMiJ9.eyJuYmYiOjE2NzUyMTM3NzksImV4cCI6MTY3NTMwMDE3OSwic3ViIjoiY2dsaXVAYW1hem9uLmNvbSIsInNhbWxfbmlmIjoiZW1haWwiLCJzYW1sX3NpIjoiXzE5N2U0M2MxLTczNjMtNGQxMi05MGEzLTYyMTNjZDdkZmMzYSJ9.M-msJk-lZnzwl9jn0RUaVauB1uLFIGe9ePG_WCCMyLHFCR0YPYhdqyyCE8OHqbB5xa4GN92sMCRkSTIsJ07j7A", + // }, + // authType: AuthType.SAML, // TODO: create constant + // expiryTime, + // }; + + const cookie: SecuritySessionCookie = { + username: "cgliu@amazon.com", + credentials: { + authHeaderValue: authToken.token, + }, + authType: AuthType.SAML, // TODO: create constant + expiryTime, + }; + + this.sessionStorageFactory.asScoped(request).set(cookie); + return response.redirected({ + headers: { + location: nextUrl, + }, + }); + } catch (error) { + context.security_plugin.logger.error( + `SAML SP initiated authentication workflow failed: ${error}` + ); + } + + return response.internalError(); + } + ); + + this.router.post( + { + path: `/_opendistro/_security/saml/acs/idpinitiated`, + validate: { + body: schema.any(), + }, + options: { + authRequired: false, + }, + }, + async (context, request, response) => { + const acsEndpoint = `${this.coreSetup.http.basePath.serverBasePath}/_opendistro/_security/saml/acs/idpinitiated`; + try { + const credentials = await this.securityClient.authToken( + undefined, + request.body.SAMLResponse, + acsEndpoint + ); + const user = await this.securityClient.authenticateWithHeader( + request, + 'authorization', + credentials.authorization + ); + + let expiryTime = Date.now() + this.config.session.ttl; + const [headerEncoded, payloadEncoded, signature] = credentials.authorization.split('.'); + if (!payloadEncoded) { + context.security_plugin.logger.error('JWT token payload not found'); + } + const tokenPayload = JSON.parse(Buffer.from(payloadEncoded, 'base64').toString()); + if (tokenPayload.exp) { + expiryTime = parseInt(tokenPayload.exp, 10) * 1000; + } + + const cookie: SecuritySessionCookie = { + username: user.username, + credentials: { + authHeaderValue: credentials.authorization, + }, + authType: AuthType.SAML, // TODO: create constant + expiryTime, + }; + this.sessionStorageFactory.asScoped(request).set(cookie); + return response.redirected({ + headers: { + location: `${this.coreSetup.http.basePath.serverBasePath}/app/opensearch-dashboards`, + }, + }); + } catch (error) { + context.security_plugin.logger.error( + `SAML IDP initiated authentication workflow failed: ${error}` + ); + } + return response.internalError(); + } + ); + + this.router.get( + { + path: SAML_AUTH_LOGOUT, + validate: false, + }, + async (context, request, response) => { + try { + const authInfo = await this.securityClient.authinfo(request); + this.sessionStorageFactory.asScoped(request).clear(); + // TODO: need a default logout page + const redirectUrl = + authInfo.sso_logout_url || this.coreSetup.http.basePath.serverBasePath || '/'; + return response.redirected({ + headers: { + location: redirectUrl, + }, + }); + } catch (error) { + context.security_plugin.logger.error(`SAML logout failed: ${error}`); + return response.badRequest(); + } + } + ); + } +} diff --git a/src/plugins/dashboards_security/server/auth/types/saml/saml_auth.ts b/src/plugins/dashboards_security/server/auth/types/saml/saml_auth.ts new file mode 100644 index 000000000000..a9f712d08be9 --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/saml/saml_auth.ts @@ -0,0 +1,136 @@ +/* + * Copyright OpenSearch Contributors + * + * 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 { escape } from 'querystring'; +import { CoreSetup } from 'opensearch-dashboards/server'; +import { SecurityPluginConfigType } from '../../..'; +import { + SessionStorageFactory, + IRouter, + ILegacyClusterClient, + OpenSearchDashboardsRequest, + AuthToolkit, + Logger, + LifecycleResponseFactory, + IOpenSearchDashboardsResponse, + AuthResult, +} from '../../../../../../src/core/server'; +import { + SecuritySessionCookie, + clearOldVersionCookieValue, +} from '../../../session/security_cookie'; +import { SamlAuthRoutes } from './routes'; +import { AuthenticationType } from '../authentication_type'; +import { AuthType, jwtKey } from '../../../../common'; + +export class SamlAuthentication extends AuthenticationType { + public static readonly AUTH_HEADER_NAME = 'authorization'; + + public readonly type: string = 'saml'; + + constructor( + config: SecurityPluginConfigType, + sessionStorageFactory: SessionStorageFactory, + router: IRouter, + esClient: ILegacyClusterClient, + coreSetup: CoreSetup, + logger: Logger + ) { + super(config, sessionStorageFactory, router, esClient, coreSetup, logger); + } + + private generateNextUrl(request: OpenSearchDashboardsRequest): string { + const path = + this.coreSetup.http.basePath.serverBasePath + + (request.url.path || '/app/opensearch-dashboards'); + return escape(path); + } + + private redirectToLoginUri(request: OpenSearchDashboardsRequest, toolkit: AuthToolkit) { + const nextUrl = this.generateNextUrl(request); + const clearOldVersionCookie = clearOldVersionCookieValue(this.config); + return toolkit.redirected({ + location: `${this.coreSetup.http.basePath.serverBasePath}/auth/saml/login?nextUrl=${nextUrl}`, + 'set-cookie': clearOldVersionCookie, + }); + } + + public async init() { + const samlAuthRoutes = new SamlAuthRoutes( + this.router, + this.config, + this.sessionStorageFactory, + this.coreSetup + ); + samlAuthRoutes.setupRoutes(); + } + + requestIncludesAuthInfo(request: OpenSearchDashboardsRequest): boolean { + return request.headers[SamlAuthentication.AUTH_HEADER_NAME] ? true : false; + } + + async getAdditionalAuthHeader(request: OpenSearchDashboardsRequest): Promise { + return {}; + } + + getCookie(request: OpenSearchDashboardsRequest, authInfo: any): SecuritySessionCookie { + return { + username: authInfo.user_name, + credentials: { + authHeaderValue: request.headers[SamlAuthentication.AUTH_HEADER_NAME], + }, + authType: AuthType.SAML, + expiryTime: Date.now() + this.config.session.ttl, + }; + } + + // Can be improved to check if the token is expiring. + async isValidCookie(cookie: SecuritySessionCookie): Promise { + // Validate JWT token in cookie + var jwt = require('jsonwebtoken'); + try { + const token = cookie.credentials.authHeaderValue; + const decodedToken = jwt.verify(token, jwtKey); + } catch (error: any) { + this.logger.error(`Failed to validate token: ${error}`); + // return false; + } + + return ( + cookie.authType === AuthType.SAML && + cookie.username && + cookie.expiryTime && + cookie.credentials?.authHeaderValue + ); + } + + handleUnauthedRequest( + request: OpenSearchDashboardsRequest, + response: LifecycleResponseFactory, + toolkit: AuthToolkit + ): IOpenSearchDashboardsResponse | AuthResult { + if (this.isPageRequest(request)) { + return this.redirectToLoginUri(request, toolkit); + } else { + return response.unauthorized(); + } + } + + buildAuthHeaderFromCookie(cookie: SecuritySessionCookie): any { + const headers: any = {}; + headers[SamlAuthentication.AUTH_HEADER_NAME] = cookie.credentials?.authHeaderValue; + return headers; + } +} diff --git a/src/plugins/dashboards_security/server/auth/types/saml/utils/AuthToken.ts b/src/plugins/dashboards_security/server/auth/types/saml/utils/AuthToken.ts new file mode 100644 index 000000000000..ac91f7060302 --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/saml/utils/AuthToken.ts @@ -0,0 +1,25 @@ +import { X509Certificate } from 'crypto'; +import { XMLParser } from 'fast-xml-parser'; +import { jwtKey } from '../../../../../common' +import { SamlAuthentication } from '../saml_auth'; +import { SAMLResponse } from './SAMLResponse'; +// import { jsonwebtoken } from 'jsonwebtoken'; + +export class AuthToken { + + token: any; + jwt: any; + + constructor(samlResponse: SAMLResponse) { + this.jwt = require('jsonwebtoken'); + const jwtExpirySeconds = "1d"; + const user = { + "cgliu@amazon.com": "123456", + } + this.token = this.jwt.sign({ username: "cgliu@amazon.com" }, jwtKey, { + algorithm: "HS256", + expiresIn: jwtExpirySeconds, + }) + // this.token = "bearer " + this.token; + } +} \ No newline at end of file diff --git a/src/plugins/dashboards_security/server/auth/types/saml/utils/SAMLResponse.ts b/src/plugins/dashboards_security/server/auth/types/saml/utils/SAMLResponse.ts new file mode 100644 index 000000000000..3209612ad7d2 --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/saml/utils/SAMLResponse.ts @@ -0,0 +1,45 @@ +import { X509Certificate } from 'crypto'; +import { XMLParser } from 'fast-xml-parser'; + +export class SAMLResponse { + private acsUrl: string | undefined; + private samlResponseDocument: any; + private jsonObj: any; + private cert: X509Certificate | undefined; + + constructor(request: any) { + if (request !== null) { + this.acsUrl = 'http://localhost:5601/_opendistro/_security/saml/acs'; + this.samlResponseDocument = request.body.SAMLResponse; + const SAML = require("saml-encoder-decoder-js"); + const xmlParser = new XMLParser(); + // TODO: + // - Validate SAML Response + // - Set the SAML Response expiry in cookie + // - Consider how identiy info updates in IDP are synced with SP(Just in time/Real Time updates) + SAML.decodeSamlPost(this.samlResponseDocument, (err: string | undefined, xml: any) => { + if (err) { + throw new Error(err); + } + this.jsonObj = xmlParser.parse(xml); + }); + // this.cert = new X509Certificate("fs.readFileSync('public-cert.pem')"); + } + } + + public isValid(samlResponse: SAMLResponse) { + // TODO: + // - validateDestination() + // - this.settings.getIdpx509cert() + // - expiry - getSessionNotOnOrAfter() + // - Validate responseInResponseTo() + // - If IDP sets getWantNameIdEncrypted to true, validate if NameID is encrypted. + // - Validate if issuer is not empty and equal to the IDP Entity ID + // - If IDP sets getWantAssertionsSigned to true, validate if Assertion is signed. + // - Validate if there's a signature + return true; + } + + + +} \ No newline at end of file diff --git a/src/plugins/dashboards_security/server/backend/opensearch_security_client.ts b/src/plugins/dashboards_security/server/backend/opensearch_security_client.ts new file mode 100755 index 000000000000..bee1be56a872 --- /dev/null +++ b/src/plugins/dashboards_security/server/backend/opensearch_security_client.ts @@ -0,0 +1,235 @@ +/* + * Copyright OpenSearch Contributors + * + * 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 { ILegacyClusterClient, OpenSearchDashboardsRequest } from '../../../../../src/core/server'; +import { User } from '../auth/user'; + +export class SecurityClient { + constructor(private readonly esClient: ILegacyClusterClient) {} + + public async authenticate(request: OpenSearchDashboardsRequest, credentials: any): Promise { + const authHeader = Buffer.from(`${credentials.username}:${credentials.password}`).toString( + 'base64' + ); + try { + const esResponse = await this.esClient + .asScoped(request) + .callAsCurrentUser('opensearch_security.authinfo', { + headers: { + authorization: `Basic ${authHeader}`, + }, + }); + return { + username: credentials.username, + roles: esResponse.roles, + backendRoles: esResponse.backend_roles, + tenants: esResponse.tenants, + selectedTenant: esResponse.user_requested_tenant, + credentials, + proxyCredentials: credentials, + }; + } catch (error: any) { + throw new Error(error.message); + } + } + + public async authenticateWithHeader( + request: OpenSearchDashboardsRequest, + headerName: string, + headerValue: string, + whitelistedHeadersAndValues: any = {}, + additionalAuthHeaders: any = {} + ): Promise { + try { + const credentials: any = { + headerName, + headerValue, + }; + const headers: any = {}; + 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 = { + user: 'User [name=admin, backend_roles=[admin], requestedTenant=null]', + user_name: 'cgliu@amazon.com', + user_requested_tenant: null, + remote_address: '127.0.0.1:61197', + backend_roles: [ 'admin' ], + custom_attribute_names: [], + roles: [ 'own_index', 'all_access' ], + tenants: { global_tenant: true, admin_tenant: true, admin: true }, + principal: null, + peer_certificates: '0', + sso_logout_url: 'https://cgliu.onelogin.com/trust/saml2/http-redirect/slo/1970238?SAMLRequest=fVJda8IwFP0rJe%2BxTbt%2BGLRMcBsFpzDHHvYit02qhTRxuSnIfv1ineAGG%2BTp5Jxzzz3JDKFXR74yezO4F%2FkxSHTBqVca%2BXgzJ4PV3AB2yDX0Erlr%2BHbxvOLxJOJHa5xpjCI3kv8VgCit64wmQbWck836YbV5qtY7EFmeQ5TQRBaM3uWipRAXMRV1Clk9bfxJSPAmLXrtnHgrb4A4yEqjA%2B08FMUJjRiN2WuU87TgLHsnwdLv02lwo%2Brg3BF5GDZ71Q0To6Uy%2B05PGtOHzg7ownP%2BODzTqJWis7LxmDIhm%2BbevSBBOTtT%2BDjZllc%2FZRpQB4OOT%2BMomoW3nItg7YuolsGjsT24vxtiEzYinaDtSOWyh04thLASkZRj8Hvo4dOMsb9HXdzLYHZ5zK3n%2Bn0rLeSp3KVtFrO8TWgt68g3mwKdZg2jbZYWeV0ULYj04vNLeQV%2F%2FI3yCw%3D%3D' + } + // const esResponse = await this.esClient + // .asScoped(request) + // .callAsCurrentUser('opensearch_security.authinfo', { + // headers, + // }); + return { + username: esResponse.user_name, + roles: esResponse.roles, + backendRoles: esResponse.backend_roles, + tenants: esResponse.tenants, + selectedTenant: esResponse.user_requested_tenant, + credentials, + }; + } catch (error: any) { + throw new Error(error.message); + } + } + + public async authenticateWithHeaders( + request: OpenSearchDashboardsRequest, + additionalAuthHeaders: any = {} + ): Promise { + try { + const esResponse = await this.esClient + .asScoped(request) + .callAsCurrentUser('opensearch_security.authinfo', { + headers: additionalAuthHeaders, + }); + return { + username: esResponse.user_name, + roles: esResponse.roles, + backendRoles: esResponse.backend_roles, + tenants: esResponse.tenants, + selectedTenant: esResponse.user_requested_tenant, + }; + } catch (error: any) { + throw new Error(error.message); + } + } + + public async authinfo(request: OpenSearchDashboardsRequest, headers: any = {}) { + try { + // return await this.esClient + // .asScoped(request) + // .callAsCurrentUser('opensearch_security.authinfo', { + // headers, + // }); + return { + user: 'User [name=admin, backend_roles=[admin], requestedTenant=null]', + user_name: 'cgliu@amazon.com', + user_requested_tenant: null, + remote_address: '127.0.0.1:61197', + backend_roles: [ 'admin' ], + custom_attribute_names: [], + roles: [ 'own_index', 'all_access' ], + tenants: { global_tenant: true, admin_tenant: true, admin: true }, + principal: null, + peer_certificates: '0', + sso_logout_url: 'https://cgliu.onelogin.com/trust/saml2/http-redirect/slo/1970238?SAMLRequest=fVJda8IwFP0rJe%2BxTbt%2BGLRMcBsFpzDHHvYit02qhTRxuSnIfv1ineAGG%2BTp5Jxzzz3JDKFXR74yezO4F%2FkxSHTBqVca%2BXgzJ4PV3AB2yDX0Erlr%2BHbxvOLxJOJHa5xpjCI3kv8VgCit64wmQbWck836YbV5qtY7EFmeQ5TQRBaM3uWipRAXMRV1Clk9bfxJSPAmLXrtnHgrb4A4yEqjA%2B08FMUJjRiN2WuU87TgLHsnwdLv02lwo%2Brg3BF5GDZ71Q0To6Uy%2B05PGtOHzg7ownP%2BODzTqJWis7LxmDIhm%2BbevSBBOTtT%2BDjZllc%2FZRpQB4OOT%2BMomoW3nItg7YuolsGjsT24vxtiEzYinaDtSOWyh04thLASkZRj8Hvo4dOMsb9HXdzLYHZ5zK3n%2Bn0rLeSp3KVtFrO8TWgt68g3mwKdZg2jbZYWeV0ULYj04vNLeQV%2F%2FI3yCw%3D%3D' + } + } catch (error: any) { + throw new Error(error.message); + } + } + + // Multi-tenancy APIs + public async getMultitenancyInfo(request: OpenSearchDashboardsRequest) { + try { + return await this.esClient + .asScoped(request) + .callAsCurrentUser('opensearch_security.multitenancyinfo'); + } catch (error: any) { + throw new Error(error.message); + } + } + + public async getTenantInfoWithInternalUser() { + try { + return this.esClient.callAsInternalUser('opensearch_security.tenantinfo'); + } catch (error: any) { + throw new Error(error.message); + } + } + + public async getTenantInfo(request: OpenSearchDashboardsRequest) { + try { + return await this.esClient + .asScoped(request) + .callAsCurrentUser('opensearch_security.tenantinfo'); + } catch (error: any) { + throw new Error(error.message); + } + } + + public async getSamlHeader(request: OpenSearchDashboardsRequest) { + try { + // response is expected to be an error + await this.esClient.asScoped(request).callAsCurrentUser('opensearch_security.authinfo'); + } catch (error: any) { + // the error looks like + // wwwAuthenticateDirective: + // ' + // X-Security-IdP realm="Open Distro Security" + // location="https:///api/saml2/v1/sso?SAMLRequest=" + // requestId="" + // ' + + // if (!error.wwwAuthenticateDirective) { + // throw error; + // } + + try { + return { + location: 'https://cgliu.onelogin.com/trust/saml2/http-redirect/sso/62513ad7-0fb6-4ef7-8065-c7d48f5ba802?SAMLRequest=fVJNj9owFPwrke%2FGTiAhsQCJLv1AooAWtodekHFewJJjp35O2%2F33NaGrbg%2B7t6fnmfHM6M1QtqYTyz5c7SP86AFD8rs1FsXwMCe9t8JJ1CisbAFFUOKw%2FLoR2YiLzrvglDPkFeV9hkQEH7SzJFmv5mS3%2FbjZfV5vT1Wual5mQKumKuikgTROqqBlDtWkgHPTyIIk38Bj5M5JlIoCiD2sLQZpQ1zxbEx5SrP0yKciK8Q4%2F06SVcyjrQwD6xpCh4IxdTG6HzkLxl20HSnXsuB7DOzmP2M3GPVQaw8q7tCxIsvTsaynlDfn6A6aKS15kVM1rSdlk59lyTOS7P%2B28UHbWtvL%2B0Wc7yAUX47HPd3vDkeSLF%2FKeXAW%2Bxb8AfxPreDpcXM3H70bp6S5OgwiL3jKTq6DKITBO3ZCUL3X4XnIwaRCspjdRjE05Rcv%2Bf9pVBnnM%2FYaM7vfwzYaXq%2F2zmj1nHxyvpXh7TzpKB02uqbNABW9xQ6UbjTUMZYx7teDBxlgTmLRQBK2uP%2F6%2F%2BEt%2FgA%3D', + requestId: 'ONELOGIN_95cd082e-9f96-4fe1-9fc6-85e946ebffa6' + } + // const locationRegExp = /location="(.*?)"/; + // const requestIdRegExp = /requestId="(.*?)"/; + + // const locationExecArray = locationRegExp.exec(error.wwwAuthenticateDirective); + // const requestExecArray = requestIdRegExp.exec(error.wwwAuthenticateDirective); + // if (locationExecArray && requestExecArray) { + // return { + // location: locationExecArray[1], + // requestId: requestExecArray[1], + // }; + // } + throw Error('failed parsing SAML config'); + } catch (parsingError: any) { + console.log(parsingError); + throw new Error(parsingError); + } + } + throw new Error(`Invalid SAML configuration.`); + } + + public async authToken( + requestId: string | undefined, + samlResponse: any, + acsEndpoint: any | undefined = undefined + ) { + const body = { + RequestId: requestId, + SAMLResponse: samlResponse, + acsEndpoint, + }; + try { + return await this.esClient.asScoped().callAsCurrentUser('opensearch_security.authtoken', { + body, + }); + } catch (error: any) { + console.log(error); + throw new Error('failed to get token'); + } + } +} From 1a54ce5aaedca8f959db82b4561ce6971e655fe4 Mon Sep 17 00:00:00 2001 From: Chang Liu Date: Wed, 15 Feb 2023 12:14:32 -0800 Subject: [PATCH 6/6] Validate SAML asserts, issue token, validate token Signed-off-by: Chang Liu --- .../dashboards_security/common/index.ts | 5 +- src/plugins/dashboards_security/package.json | 5 +- .../server/auth/types/saml/routes.ts | 151 +++++++++++------- .../server/auth/types/saml/utils/HapiSaml.ts | 64 ++++++++ .../auth/types/saml/utils/SAMLResponse.ts | 62 +++---- .../server/auth/types/saml/utils/metadata.xml | 39 +++++ 6 files changed, 229 insertions(+), 97 deletions(-) create mode 100644 src/plugins/dashboards_security/server/auth/types/saml/utils/HapiSaml.ts create mode 100644 src/plugins/dashboards_security/server/auth/types/saml/utils/metadata.xml diff --git a/src/plugins/dashboards_security/common/index.ts b/src/plugins/dashboards_security/common/index.ts index 37cedca9cbb1..eac3231d6e84 100644 --- a/src/plugins/dashboards_security/common/index.ts +++ b/src/plugins/dashboards_security/common/index.ts @@ -28,7 +28,10 @@ export const AUTH_HEADER_NAME = 'authorization'; export const AUTH_GRANT_TYPE = 'authorization_code'; export const AUTH_RESPONSE_TYPE = 'code'; -export const jwtKey = "6aff3042-1327-4f3d-82f0-40a157ac4464" +export const jwtKey = '6aff3042-1327-4f3d-82f0-40a157ac4464'; + +export const idpCert = + 'MIIDzzCCAregAwIBAgIUKizt/svOXO4USLQ3spS2Bn507LYwDQYJKoZIhvcNAQEFBQAwQTEMMAoGA1UECgwDQVdTMRUwEwYDVQQLDAxPbmVMb2dpbiBJZFAxGjAYBgNVBAMMEU9uZUxvZ2luIEFjY291bnQgMB4XDTIzMDExMzIwMTQzNVoXDTI4MDExMzIwMTQzNVowQTEMMAoGA1UECgwDQVdTMRUwEwYDVQQLDAxPbmVMb2dpbiBJZFAxGjAYBgNVBAMMEU9uZUxvZ2luIEFjY291bnQgMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwTX1daRM90aJmDCWTL3Iuj4GvK2nHRNZoLP9dzscbFJNMIQEXdyREHSVnFO18KWDfwX3gOgvcuijJUk+r5XCf1oJueUNhme/Q8eSHQe1TOhOVPXuI9BxMyPupeKfmFelIylTNvUoCQo2A/dJURRN2rjz4pOoCqadOlgm2So//J8I/JiZVO6S1YleAjWY5VYOMJMq8QKBBMKkmxok+reA36lmvi2JtUZWpZVo62XVcjP9+uOONyXo7O3VEu8Vwezex2sXFyCm699G1aeRCtHQ3yKmhf0Rm0D+RgZKnG+9i6aeJFTXluBqOrz6CtXtW0SV2NKIeK36EcMH1unlG4/VMwIDAQABo4G+MIG7MAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFKsWx05elVPbItGUYA3SBXVehP7VMHwGA1UdIwR1MHOAFKsWx05elVPbItGUYA3SBXVehP7VoUWkQzBBMQwwCgYDVQQKDANBV1MxFTATBgNVBAsMDE9uZUxvZ2luIElkUDEaMBgGA1UEAwwRT25lTG9naW4gQWNjb3VudCCCFCos7f7LzlzuFEi0N7KUtgZ+dOy2MA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQUFAAOCAQEAh0Kg8BQrOuWO30A6Qj+VL2Ke0/Y96hgdjYxk4zcIwZcIxfb5U733ftF2H0r8RKBYNrWpEmPwa4RnaTqwRaY/pahZ7kznzgMVUMhT9QZe4uNDLu5HgzAuOdhpYk2qv6+GYqcbMNtKPEtTjp0/KwMntgBkn9dPBSiydqojtwh0i2e2rhFh4gBDvuXdHZCcOWCKYm24IOoEI41Q4JIu1jAk6LM3jErcZdx+Lqa9rvSn6jdC6/jwhR1anqqLU9qGIjN99640z/JIOdK8wPei2veLpZbKIDtG/iaSNkdrFhEE1WNXTnnPImQNVgvIT9QdyOLLdzuQ25G3Qraj47JEMm0Xmw=='; export enum AuthType { BASIC = 'basicauth', diff --git a/src/plugins/dashboards_security/package.json b/src/plugins/dashboards_security/package.json index d5ebb1f8c625..e0f6e971ed32 100644 --- a/src/plugins/dashboards_security/package.json +++ b/src/plugins/dashboards_security/package.json @@ -36,6 +36,9 @@ "dependencies": { "@hapi/cryptiles": "5.0.0", "@hapi/wreck": "^17.1.0", - "html-entities": "1.3.1" + "html-entities": "1.3.1", + "node-saml": "^4.0.0-beta.2", + "@node-saml/passport-saml": "4.0.2", + "jsonwebtoken": "9.0.0" } } diff --git a/src/plugins/dashboards_security/server/auth/types/saml/routes.ts b/src/plugins/dashboards_security/server/auth/types/saml/routes.ts index 10b083b411f3..31fef97d3c59 100644 --- a/src/plugins/dashboards_security/server/auth/types/saml/routes.ts +++ b/src/plugins/dashboards_security/server/auth/types/saml/routes.ts @@ -1,3 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + /* * Copyright OpenSearch Contributors * @@ -14,6 +19,8 @@ */ import { schema } from '@osd/config-schema'; +import { compileSchema } from 'ajv/dist/compile'; +import { XMLParser } from 'fast-xml-parser'; import { IRouter, SessionStorageFactory, @@ -24,10 +31,9 @@ import { SecurityPluginConfigType } from '../../..'; import { SecurityClient } from '../../../backend/opensearch_security_client'; import { CoreSetup } from '../../../../../../../src/core/server'; import { validateNextUrl } from '../../../utils/next_url'; -import { AuthType, SAML_AUTH_LOGIN, SAML_AUTH_LOGOUT } from '../../../../common'; -import { compileSchema } from 'ajv/dist/compile'; -import { XMLParser } from "fast-xml-parser"; +import { AuthType, idpCert, SAML_AUTH_LOGIN, SAML_AUTH_LOGOUT } from '../../../../common'; import { AuthToken } from './utils/AuthToken'; +import { HapiSaml } from './utils/HapiSaml'; export class SamlAuthRoutes { constructor( @@ -119,70 +125,99 @@ export class SamlAuthRoutes { } try { - const SAML = require("saml-encoder-decoder-js"); + const authInfo = await this.securityClient.authinfo(request); + + const samlOptions = { + // passport saml settings + + saml: { + // this should be the same as the assert path in config below + callbackUrl: '/auth/saml/login', + // logout functionality is untested at this time. + logoutCallbackUrl: 'http://localhost/api/sso/v1/notifylogout', + logoutUrl: + authInfo.sso_logout_url || this.coreSetup.http.basePath.serverBasePath || '/', + + entryPoint: 'https://cgliu.onelogin.com/trust/saml2/http-redirect/slo/1970238', + privateKey: '', + // IdP Public Signing Key + cert: idpCert, + issuer: 'one_login', + }, + // hapi-saml-sp settings + config: { + // public cert provided in metadata + signingCert: '', + // Plugin Routes + routes: { + metadata: { + path: './utils/metadata.xml', + options: { + description: 'Fetch SAML metadata', + tags: ['api'], + }, + }, + assert: { + path: `/_opendistro/_security/saml/acs`, + options: { + description: 'SAML login endpoint', + tags: ['api'], + }, + }, + }, + assertHooks: { + // This will get called after your SAML identity provider sends a + // POST request back to the assert endpoint specified above (e.g. /login/saml) + onResponse: ( + profile: any, + request: any, + h: { redirect: (arg0: string) => any } + ) => { + // your custom handling code goes in here. I can't help much with this, + // but you could set a cookie, or generate a JWT and h.redirect() your user to your + // front end with that. + return h.redirect('https://your.frontend.test'); + }, + }, + }, + }; + + const hapiSaml = new HapiSaml(samlOptions); + const saml = hapiSaml.getSamlLib(); + + const SAMLResponse = request.body.SAMLResponse; + let profile = null; + try { + profile = (await saml.validatePostResponseAsync({ SAMLResponse })) || {}; + } catch (error: any) { + context.security_plugin.logger.error(`Error while validating SAML response: ${error}`); + return response.internalError(); + } + + if (profile === null) { + return response.internalError(); + } + + const SAML = require('saml-encoder-decoder-js'); const xmlParser = new XMLParser(); const samlResponse = request.body.SAMLResponse; - // TODO: - // - Validate SAML Response - // - Set the SAML Response expiry in cookie - // - Consider how identiy info updates in IDP are synced with SP(Just in time/Real Time updates) - SAML.decodeSamlPost(samlResponse, function(err: string | undefined, xml: any) { - if (err) { - throw new Error(err); - } - const jsonObj = xmlParser.parse(xml); - const username = jsonObj["samlp:Response"]["saml:Assertion"]["saml:Subject"]["saml:NameID"]; - }); - - - // const credentials = await this.securityClient.authToken( - // requestId, - // request.body.SAMLResponse, - // undefined - // ); - // const user = await this.securityClient.authenticateWithHeader( - // request, - // 'authorization', - // credentials.authorization - // ); - let expiryTime = Date.now() + this.config.session.ttl; - // const [headerEncoded, payloadEncoded, signature] = credentials.authorization.split('.'); - // if (!payloadEncoded) { - // context.security_plugin.logger.error('JWT token payload not found'); - // } - // const tokenPayload = JSON.parse(Buffer.from(payloadEncoded, 'base64').toString()); + SAML.decodeSamlPost(samlResponse, function (err: string | undefined, xml: any) { + if (err) { + throw new Error(err); + } + const jsonObj = xmlParser.parse(xml); + const username = + jsonObj['samlp:Response']['saml:Assertion']['saml:Subject']['saml:NameID']; + }); - // if (tokenPayload.exp) { - // expiryTime = parseInt(tokenPayload.exp, 10) * 1000; - // } - // const cookie: SecuritySessionCookie = { - // username: user.username, - // credentials: { - // authHeaderValue: credentials.authorization, - // }, - // authType: AuthType.SAML, // TODO: create constant - // expiryTime, - // }; - // console.log("######cookie"); - // console.log(cookie); + const expiryTime = Date.now() + this.config.session.ttl; const authToken = new AuthToken(samlResponse); const credentials = authToken.token; - console.log("######### credentials"); - console.log(credentials); - - // const cookie: SecuritySessionCookie = { - // username: "cgliu@amazon.com", - // credentials: { - // authHeaderValue: "bearer eyJhbGciOiJIUzUxMiJ9.eyJuYmYiOjE2NzUyMTM3NzksImV4cCI6MTY3NTMwMDE3OSwic3ViIjoiY2dsaXVAYW1hem9uLmNvbSIsInNhbWxfbmlmIjoiZW1haWwiLCJzYW1sX3NpIjoiXzE5N2U0M2MxLTczNjMtNGQxMi05MGEzLTYyMTNjZDdkZmMzYSJ9.M-msJk-lZnzwl9jn0RUaVauB1uLFIGe9ePG_WCCMyLHFCR0YPYhdqyyCE8OHqbB5xa4GN92sMCRkSTIsJ07j7A", - // }, - // authType: AuthType.SAML, // TODO: create constant - // expiryTime, - // }; const cookie: SecuritySessionCookie = { - username: "cgliu@amazon.com", + username: 'cgliu@amazon.com', credentials: { authHeaderValue: authToken.token, }, diff --git a/src/plugins/dashboards_security/server/auth/types/saml/utils/HapiSaml.ts b/src/plugins/dashboards_security/server/auth/types/saml/utils/HapiSaml.ts new file mode 100644 index 000000000000..e7693ed51b92 --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/saml/utils/HapiSaml.ts @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SAML } from 'node-saml'; + +('use strict'); + +// const Saml = require('passport-saml/lib/node-saml/saml'); +// const Saml = SAML + +export class HapiSaml { + saml: any; + props: {}; + + constructor(options: any) { + console.log('!!!options'); + console.log(options); + + this.saml = null; + this.props = {}; + this.load(options); + } + + load(options: any) { + if (!options.saml) { + throw new Error('Missing options.saml'); + } + + if (!options.config && !options.config.routes) { + throw new Error('Missing options.config.routes'); + } + + if (!options.config.routes.metadata) { + throw new Error('Missing options.config.routes.metadata'); + } + + if (!options.config.routes.assert) { + throw new Error('Missing options.config.routes.assert'); + } + + if (!options.config && !options.config.assertHooks.onRequest) { + throw new Error('Missing options.config.assertHooks.onRequest'); + } + + if (!options.config && !options.config.assertHooks.onResponse) { + throw new Error('Missing options.config.assertHooks.onResponse'); + } + + console.log('777777'); + + this.saml = new SAML(options.saml); + this.props = Object.assign({}, options.saml); + this.props.decryptionCert = options.config.decryptionCert; + this.props.signingCert = options.config.signingCert; + } + + getSamlLib() { + return this.saml; + } +} + +exports.HapiSaml = HapiSaml; diff --git a/src/plugins/dashboards_security/server/auth/types/saml/utils/SAMLResponse.ts b/src/plugins/dashboards_security/server/auth/types/saml/utils/SAMLResponse.ts index 3209612ad7d2..21cba518b73b 100644 --- a/src/plugins/dashboards_security/server/auth/types/saml/utils/SAMLResponse.ts +++ b/src/plugins/dashboards_security/server/auth/types/saml/utils/SAMLResponse.ts @@ -1,45 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + import { X509Certificate } from 'crypto'; import { XMLParser } from 'fast-xml-parser'; export class SAMLResponse { - private acsUrl: string | undefined; - private samlResponseDocument: any; - private jsonObj: any; - private cert: X509Certificate | undefined; + private acsUrl: string | undefined; + private samlResponseDocument: any; + private jsonObj: any; + private cert: X509Certificate | undefined; - constructor(request: any) { - if (request !== null) { - this.acsUrl = 'http://localhost:5601/_opendistro/_security/saml/acs'; - this.samlResponseDocument = request.body.SAMLResponse; - const SAML = require("saml-encoder-decoder-js"); - const xmlParser = new XMLParser(); - // TODO: - // - Validate SAML Response - // - Set the SAML Response expiry in cookie - // - Consider how identiy info updates in IDP are synced with SP(Just in time/Real Time updates) - SAML.decodeSamlPost(this.samlResponseDocument, (err: string | undefined, xml: any) => { - if (err) { - throw new Error(err); - } - this.jsonObj = xmlParser.parse(xml); - }); - // this.cert = new X509Certificate("fs.readFileSync('public-cert.pem')"); + constructor(request: any) { + if (request !== null) { + this.acsUrl = 'http://localhost:5601/_opendistro/_security/saml/acs'; + this.samlResponseDocument = request.body.SAMLResponse; + const SAML = require('saml-encoder-decoder-js'); + const xmlParser = new XMLParser(); + SAML.decodeSamlPost(this.samlResponseDocument, (err: string | undefined, xml: any) => { + if (err) { + throw new Error(err); } + this.jsonObj = xmlParser.parse(xml); + }); } + } - public isValid(samlResponse: SAMLResponse) { - // TODO: - // - validateDestination() - // - this.settings.getIdpx509cert() - // - expiry - getSessionNotOnOrAfter() - // - Validate responseInResponseTo() - // - If IDP sets getWantNameIdEncrypted to true, validate if NameID is encrypted. - // - Validate if issuer is not empty and equal to the IDP Entity ID - // - If IDP sets getWantAssertionsSigned to true, validate if Assertion is signed. - // - Validate if there's a signature - return true; - } - - - -} \ No newline at end of file + public isValid(samlResponse: SAMLResponse) { + return true; + } +} diff --git a/src/plugins/dashboards_security/server/auth/types/saml/utils/metadata.xml b/src/plugins/dashboards_security/server/auth/types/saml/utils/metadata.xml new file mode 100644 index 000000000000..2a13412db3e8 --- /dev/null +++ b/src/plugins/dashboards_security/server/auth/types/saml/utils/metadata.xml @@ -0,0 +1,39 @@ + + + + + + + MIIDzzCCAregAwIBAgIUKizt/svOXO4USLQ3spS2Bn507LYwDQYJKoZIhvcNAQEF +BQAwQTEMMAoGA1UECgwDQVdTMRUwEwYDVQQLDAxPbmVMb2dpbiBJZFAxGjAYBgNV +BAMMEU9uZUxvZ2luIEFjY291bnQgMB4XDTIzMDExMzIwMTQzNVoXDTI4MDExMzIw +MTQzNVowQTEMMAoGA1UECgwDQVdTMRUwEwYDVQQLDAxPbmVMb2dpbiBJZFAxGjAY +BgNVBAMMEU9uZUxvZ2luIEFjY291bnQgMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAwTX1daRM90aJmDCWTL3Iuj4GvK2nHRNZoLP9dzscbFJNMIQEXdyR +EHSVnFO18KWDfwX3gOgvcuijJUk+r5XCf1oJueUNhme/Q8eSHQe1TOhOVPXuI9Bx +MyPupeKfmFelIylTNvUoCQo2A/dJURRN2rjz4pOoCqadOlgm2So//J8I/JiZVO6S +1YleAjWY5VYOMJMq8QKBBMKkmxok+reA36lmvi2JtUZWpZVo62XVcjP9+uOONyXo +7O3VEu8Vwezex2sXFyCm699G1aeRCtHQ3yKmhf0Rm0D+RgZKnG+9i6aeJFTXluBq +Orz6CtXtW0SV2NKIeK36EcMH1unlG4/VMwIDAQABo4G+MIG7MAwGA1UdEwEB/wQC +MAAwHQYDVR0OBBYEFKsWx05elVPbItGUYA3SBXVehP7VMHwGA1UdIwR1MHOAFKsW +x05elVPbItGUYA3SBXVehP7VoUWkQzBBMQwwCgYDVQQKDANBV1MxFTATBgNVBAsM +DE9uZUxvZ2luIElkUDEaMBgGA1UEAwwRT25lTG9naW4gQWNjb3VudCCCFCos7f7L +zlzuFEi0N7KUtgZ+dOy2MA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQUFAAOC +AQEAh0Kg8BQrOuWO30A6Qj+VL2Ke0/Y96hgdjYxk4zcIwZcIxfb5U733ftF2H0r8 +RKBYNrWpEmPwa4RnaTqwRaY/pahZ7kznzgMVUMhT9QZe4uNDLu5HgzAuOdhpYk2q +v6+GYqcbMNtKPEtTjp0/KwMntgBkn9dPBSiydqojtwh0i2e2rhFh4gBDvuXdHZCc +OWCKYm24IOoEI41Q4JIu1jAk6LM3jErcZdx+Lqa9rvSn6jdC6/jwhR1anqqLU9qG +IjN99640z/JIOdK8wPei2veLpZbKIDtG/iaSNkdrFhEE1WNXTnnPImQNVgvIT9Qd +yOLLdzuQ25G3Qraj47JEMm0Xmw== + + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + + + + \ No newline at end of file