diff --git a/common/index.ts b/common/index.ts index b5e6a475d..b504cc4aa 100644 --- a/common/index.ts +++ b/common/index.ts @@ -24,6 +24,7 @@ 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_DASHBOARDSINFO = API_PREFIX + '/auth/dashboardsinfo'; +export const API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS = API_ENDPOINT_DASHBOARDSINFO + '/signinoptions'; export const API_ENDPOINT_AUTHTYPE = API_PREFIX + '/auth/type'; export const LOGIN_PAGE_URI = '/app/' + APP_ID_LOGIN; export const CUSTOM_ERROR_PAGE_URI = '/app/' + APP_ID_CUSTOMERROR; diff --git a/public/apps/configuration/panels/auth-view/auth-view.tsx b/public/apps/configuration/panels/auth-view/auth-view.tsx index 80a8651bf..b6d6f8fc2 100644 --- a/public/apps/configuration/panels/auth-view/auth-view.tsx +++ b/public/apps/configuration/panels/auth-view/auth-view.tsx @@ -23,10 +23,16 @@ import { AppDependencies } from '../../../types'; import { ExternalLinkButton } from '../../utils/display-utils'; import { getSecurityConfig } from '../../utils/auth-view-utils'; import { InstructionView } from './instruction-view'; +import { SignInOptionsPanel } from './dashboard-signin-options'; +import { DashboardSignInOptions } from '../../types'; export function AuthView(props: AppDependencies) { const [authentication, setAuthentication] = React.useState([]); const [authorization, setAuthorization] = React.useState([]); + const [dashboardSignInOptions, setDashboardSignInOptions] = React.useState< + DashboardSignInOptions[] + >([]); + const [isAnonymousAuthEnable, setAnonymousAuthEnable] = useState(false); const [loading, setLoading] = useState(false); React.useEffect(() => { @@ -37,6 +43,8 @@ export function AuthView(props: AppDependencies) { setAuthentication(config.authc); setAuthorization(config.authz); + setDashboardSignInOptions(config.kibana.sign_in_options); + setAnonymousAuthEnable(config.http.anonymous_auth_enabled); } catch (e) { console.log(e); } finally { @@ -68,6 +76,14 @@ export function AuthView(props: AppDependencies) { {/* @ts-ignore */} + + + {/* @ts-ignore */} ); diff --git a/public/apps/configuration/panels/auth-view/dashboard-signin-options.tsx b/public/apps/configuration/panels/auth-view/dashboard-signin-options.tsx new file mode 100644 index 000000000..eb15e7c98 --- /dev/null +++ b/public/apps/configuration/panels/auth-view/dashboard-signin-options.tsx @@ -0,0 +1,182 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { + EuiBasicTableColumn, + EuiFlexGroup, + EuiGlobalToastList, + EuiHealth, + EuiHorizontalRule, + EuiInMemoryTable, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPanel, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { get, keys } from 'lodash'; +import { HttpSetup } from 'opensearch-dashboards/public'; +import React, { useEffect, useState } from 'react'; +import { updateDashboardSignInOptions } from '../../../../utils/dashboards-info-utils'; +import { DashboardOption, DashboardSignInOptions } from '../../types'; +import { createErrorToast, createSuccessToast, useToastState } from '../../utils/toast-utils'; +import { SignInOptionsModal } from './signin-options-modal'; + +interface SignInOptionsPanelProps { + authc: []; + signInEnabledOptions: DashboardSignInOptions[]; + http: HttpSetup; + isAnonymousAuthEnable: boolean; +} + +export const columns: Array> = [ + { + field: 'displayName', + name: 'Name', + 'data-test-subj': 'name', + mobileOptions: { + render: (opt: DashboardOption) => {opt.name}, + header: false, + truncateText: false, + enlarge: true, + width: '100%', + }, + sortable: true, + }, + { + field: 'status', + name: 'Status', + dataType: 'boolean', + render: (enable: DashboardOption['status']) => { + const color = enable ? 'success' : 'danger'; + const label = enable ? 'Enable' : 'Disable'; + return {label}; + }, + }, +]; + +export function SignInOptionsPanel(props: SignInOptionsPanelProps) { + const { authc, signInEnabledOptions, http, isAnonymousAuthEnable } = props; + + const domains = keys(authc); + const [toasts, addToast, removeToast] = useToastState(); + const [dashboardOptions, setDashboardOptions] = useState([]); + + enum makeAuthTypeHumanReadable { + BASIC = 'Basic Authentication', + SAML = 'SAML', + OPENID = 'OpenID Connect', + ANONYMOUS = 'Anonymous', + } + + useEffect(() => { + const getDasboardOptions = () => { + const options = domains + .map((domain) => { + const data = get(authc, domain); + + const option = data.http_authenticator.type.toUpperCase(); + if (option in DashboardSignInOptions) { + const dashboardOption: DashboardOption = { + name: option, + status: signInEnabledOptions.indexOf(option) > -1, + displayName: makeAuthTypeHumanReadable[option], + }; + return dashboardOption; + } + }) + .filter((option) => option != null) + .filter( + (option, index, arr) => arr.findIndex((opt) => opt?.name === option?.name) === index + ) as DashboardOption[]; + setDashboardOptions(options); + }; + + if (signInEnabledOptions.length > 0 && dashboardOptions.length === 0) { + getDasboardOptions(); + } + }, [signInEnabledOptions, authc, dashboardOptions, domains]); + + useEffect(() => { + if (isAnonymousAuthEnable) { + const option = DashboardSignInOptions.ANONYMOUS; + const anonymousOption: DashboardOption = { + name: DashboardSignInOptions[option], + status: signInEnabledOptions.indexOf(DashboardSignInOptions[option]) > -1, + displayName: makeAuthTypeHumanReadable.ANONYMOUS, + }; + + setDashboardOptions((prevState) => [...prevState, anonymousOption]); + } + }, [signInEnabledOptions, isAnonymousAuthEnable]); + + const handleUpdate = async (newSignInOptions: DashboardOption[]) => { + await updateDashboardSignInOptions( + props.http, + newSignInOptions.map((opt) => opt.name as DashboardSignInOptions) + ) + .then(() => { + setDashboardOptions((prevOptions) => + prevOptions.map((option) => { + option.status = newSignInOptions.includes(option); + return option; + }) + ); + + addToast( + createSuccessToast('updatePassed', 'Dashboard SignIn Options', 'Changes applied.') + ); + }) + .catch((e) => { + console.log('The sign in options could not be updated'); + console.log(e); + addToast( + createErrorToast('updatedError', 'Dashboard SignIn Options', 'Error updating values.') + ); + }); + }; + + return ( + + + + +

Dashboards sign-in options

+
+ +

+ Configure one or multiple authentication options to appear on the sign-in window for + OpenSearch Dashboards. +

+
+
+ + + + + +
+ + + +
+ ); +} diff --git a/public/apps/configuration/panels/auth-view/signin-options-modal.tsx b/public/apps/configuration/panels/auth-view/signin-options-modal.tsx new file mode 100644 index 000000000..84fef3566 --- /dev/null +++ b/public/apps/configuration/panels/auth-view/signin-options-modal.tsx @@ -0,0 +1,106 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React, { Dispatch, SetStateAction } from 'react'; +import { DashboardOption } from '../../types'; + +import { + EuiButton, + EuiInMemoryTable, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, +} from '@elastic/eui'; +import { columns } from './dashboard-signin-options'; + +interface DashboardSignInProps { + dashboardOptions: DashboardOption[]; + setDashboardOptions: Dispatch>; + handleUpdate: Function; +} + +export function SignInOptionsModal(props: DashboardSignInProps): JSX.Element { + const [newSignInOptions, setNewSignInOptions] = React.useState([]); + const [disableUpdate, disableUpdateButton] = React.useState(false); + const actualSignInOptions: DashboardOption[] = props.dashboardOptions.filter((opt) => opt.status); + + const [isModalVisible, setIsModalVisible] = React.useState(false); + const closeModal = () => setIsModalVisible(false); + const showModal = () => setIsModalVisible(true); + + React.useEffect(() => { + if (actualSignInOptions.length !== newSignInOptions.length && newSignInOptions.length > 0) { + disableUpdateButton(false); + } else { + let sameOptions = true; + newSignInOptions.forEach((option) => { + if (actualSignInOptions.includes(option) === false) { + sameOptions = false; + return; + } + }); + disableUpdateButton(sameOptions); + } + }, [newSignInOptions, actualSignInOptions]); + + let modal; + + if (isModalVisible) { + modal = ( + + + Dashboards sign-in options + + + Select one or multiple authentication options to appear on the sign-in window for + OpenSearch Dashboards. + + + + + Cancel + { + props.handleUpdate(newSignInOptions); + closeModal(); + }} + fill + disabled={disableUpdate} + > + Update + + + + ); + } + return ( +
+ + Edit + + {modal} +
+ ); +} diff --git a/public/apps/configuration/panels/auth-view/test/__snapshots__/signin-options-modal.test.tsx.snap b/public/apps/configuration/panels/auth-view/test/__snapshots__/signin-options-modal.test.tsx.snap new file mode 100644 index 000000000..50abdde1d --- /dev/null +++ b/public/apps/configuration/panels/auth-view/test/__snapshots__/signin-options-modal.test.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Test sign-in options modal Render should render SignIn Options modal with 2 options 1`] = ` +
+ + Edit + + + + + Dashboard Sign In Options + + + + Enable/Disable sign-in options for OpenSearch Dashboard. + + + + + + Cancel + + + Update + + + +
+`; diff --git a/public/apps/configuration/panels/auth-view/test/signin-options-modal.test.tsx b/public/apps/configuration/panels/auth-view/test/signin-options-modal.test.tsx new file mode 100644 index 000000000..899af910c --- /dev/null +++ b/public/apps/configuration/panels/auth-view/test/signin-options-modal.test.tsx @@ -0,0 +1,70 @@ +/* + * 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 { EuiBasicTable } from '@elastic/eui'; +import { shallow } from 'enzyme'; +import React from 'react'; +import { DashboardSignInOptions } from '../../../types'; +import { SignInOptionsModal } from '../signin-options-modal'; + +describe('Test sign-in options modal', () => { + const initialValues = [ + { name: DashboardSignInOptions[DashboardSignInOptions.BASIC], status: true, displayName: "Basic Authentication" }, + { name: DashboardSignInOptions[DashboardSignInOptions.SAML], status: false, displayName: "SAML" }, + ]; + + const useEffectMock = jest.spyOn(React, 'useEffect'); + const handleUpdate = jest.fn(); + + let component; + + beforeEach(() => { + component = shallow( + {}} + handleUpdate={handleUpdate} + /> + ); + }); + + describe('Render', () => { + it('should render SignIn Options modal with 2 options', () => { + component.find('[data-testid="edit"]').simulate('click'); + + expect(component).toMatchSnapshot(); + expect(component.find(EuiBasicTable).length).toBe(1); + expect(component.find(EuiBasicTable).prop('items').length).toBe(2); + }); + + it("'Update' button should be disabled", () => { + useEffectMock.mockImplementationOnce((f) => f()); + + component.find('[data-testid="edit"]').simulate('click'); + + expect(component.find('[data-testid="update"]').length).toBe(1); + expect(component.find('[data-testid="update"]').prop('disabled')).toBe(true); + }); + }); + + describe('Action', () => { + it('click Update should call handleUpdate function', () => { + component.find('[data-testid="edit"]').simulate('click'); + component.find('[data-testid="update"]').simulate('click'); + + expect(handleUpdate).toBeCalledTimes(1); + }); + }); +}); diff --git a/public/apps/configuration/types.ts b/public/apps/configuration/types.ts index 967072d18..eb753a85a 100644 --- a/public/apps/configuration/types.ts +++ b/public/apps/configuration/types.ts @@ -149,3 +149,16 @@ export interface FormRowDeps { helpLink?: string; helpText?: string; } + +export enum DashboardSignInOptions { + BASIC, + SAML, + OPENID, + ANONYMOUS, +} + +export interface DashboardOption { + name: DashboardSignInOptions | string; + status: boolean; + displayName: string; +} diff --git a/public/apps/login/login-page.tsx b/public/apps/login/login-page.tsx index a22a36dc7..2edd40483 100644 --- a/public/apps/login/login-page.tsx +++ b/public/apps/login/login-page.tsx @@ -13,7 +13,7 @@ * permissions and limitations under the License. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiText, EuiFieldText, @@ -35,6 +35,8 @@ import { OPENID_AUTH_LOGIN_WITH_FRAGMENT, SAML_AUTH_LOGIN_WITH_FRAGMENT, } from '../../../common'; +import { getDashboardsSignInOptions } from '../../utils/dashboards-info-utils'; +import { DashboardSignInOptions } from '../configuration/types'; import { getSavedTenant } from '../../utils/storage-utils'; interface LoginPageDeps { @@ -100,6 +102,29 @@ export function LoginPage(props: LoginPageDeps) { const [loginError, setloginError] = useState(''); const [usernameValidationFailed, setUsernameValidationFailed] = useState(false); const [passwordValidationFailed, setPasswordValidationFailed] = useState(false); + const [signInOptions, setSignInOptions] = React.useState([]); + + // It will confirm that the sign-in option is still available. If not, it will reload the login page with the available options. + const reValidateSignInOption = async (option: DashboardSignInOptions) => { + const dashboardSignInOptions = await getDashboardsSignInOptions(props.http); + const isValidOption = dashboardSignInOptions.includes(DashboardSignInOptions[option]); + if (isValidOption === false) { + window.location.reload(); + } + }; + + React.useEffect(() => { + const getSignInOptions = async () => { + try { + const dashboardSignInOptions = await getDashboardsSignInOptions(props.http); + setSignInOptions(dashboardSignInOptions); + } catch (e) { + console.error(`Unable to get sign in options ${e}`); + } + }; + + getSignInOptions(); + }, [props.http]); let errorLabel: any = null; if (loginFailed) { @@ -130,6 +155,8 @@ export function LoginPage(props: LoginPageDeps) { return; } + await reValidateSignInOption(DashboardSignInOptions.BASIC); + try { await validateCurrentPassword(props.http, username, password); redirect(props.http.basePath.serverBasePath); @@ -156,6 +183,9 @@ export function LoginPage(props: LoginPageDeps) { size="s" type="prime" className={buttonConfig.buttonstyle || 'btn-login'} + onClick={async () => + await reValidateSignInOption(DashboardSignInOptions[authType.toUpperCase()]) + } href={loginEndPointWithPath} iconType={buttonConfig.showbrandimage ? buttonConfig.brandimage : ''} > @@ -165,22 +195,40 @@ export function LoginPage(props: LoginPageDeps) { ); }; + const mapSignInOptions = (options: DashboardSignInOptions[]) => { + const authOpts = []; + for (let i = 0; i < options.length; i++) { + // Dashboard sign-in options are taken from HTTP type property where the value is 'openid' and it needs to match with AuthType open_id; + if (DashboardSignInOptions[options[i]] === DashboardSignInOptions.OPENID) { + authOpts.push(AuthType.OPEN_ID); + } else { + const authType = AuthType[options[i]]; + if (authType) { + authOpts.push(authType); + } + } + } + return authOpts; + }; + const formOptions = (options: string | string[]) => { let formBody = []; const formBodyOp = []; - let authOpts = []; + let authOpts = mapSignInOptions(signInOptions); - if (typeof options === 'string') { - if (options === '') { - authOpts.push(AuthType.BASIC); - } else { - authOpts.push(options.toLowerCase()); - } - } else { - if (options && options.length === 1 && options[0] === '') { - authOpts.push(AuthType.BASIC); + if (authOpts.length === 0) { + if (typeof options === 'string') { + if (options === '') { + authOpts.push(AuthType.BASIC); + } else { + authOpts.push(options.toLowerCase()); + } } else { - authOpts = [...options]; + if (options && options.length === 1 && options[0] === '') { + authOpts.push(AuthType.BASIC); + } else { + authOpts = [...options]; + } } } @@ -191,6 +239,7 @@ export function LoginPage(props: LoginPageDeps) { } @@ -204,6 +253,7 @@ export function LoginPage(props: LoginPageDeps) { } @@ -218,6 +268,7 @@ export function LoginPage(props: LoginPageDeps) { formBody.push( ); - - if (authOpts.length > 1) { - if (props.config.auth.anonymous_auth_enabled) { - const anonymousConfig = props.config.ui[AuthType.ANONYMOUS].login; - formBody.push( - renderLoginButton(AuthType.ANONYMOUS, ANONYMOUS_AUTH_LOGIN, anonymousConfig) - ); - } - - formBody.push(); - formBody.push(); - formBody.push(); - } break; } case AuthType.OPEN_ID: { @@ -259,6 +297,13 @@ export function LoginPage(props: LoginPageDeps) { formBodyOp.push(renderLoginButton(AuthType.SAML, samlAuthLoginUrl, samlConfig)); break; } + case AuthType.ANONYMOUS: { + const anonymousConfig = props.config.ui[AuthType.ANONYMOUS].login; + formBody.push( + renderLoginButton(AuthType.ANONYMOUS, ANONYMOUS_AUTH_LOGIN, anonymousConfig) + ); + break; + } default: { setloginFailed(true); setloginError( @@ -269,6 +314,12 @@ export function LoginPage(props: LoginPageDeps) { } } + if (authOpts.length > 1) { + formBody.push(); + formBody.push(); + formBody.push(); + } + formBody = formBody.concat(formBodyOp); return formBody; }; diff --git a/public/types.ts b/public/types.ts index 4acfc442f..d15af6d41 100644 --- a/public/types.ts +++ b/public/types.ts @@ -47,6 +47,7 @@ export interface DashboardsInfo { private_tenant_enabled?: boolean; default_tenant: string; password_validation_error_message: string; + sign_in_options: []; } export interface ClientConfigType { diff --git a/public/utils/dashboards-info-utils.tsx b/public/utils/dashboards-info-utils.tsx index eeb76a345..ea73a041b 100644 --- a/public/utils/dashboards-info-utils.tsx +++ b/public/utils/dashboards-info-utils.tsx @@ -14,9 +14,10 @@ */ import { HttpStart } from 'opensearch-dashboards/public'; -import { API_ENDPOINT_DASHBOARDSINFO } from '../../common'; -import { httpGet, httpGetWithIgnores } from '../apps/configuration/utils/request-utils'; +import { API_ENDPOINT_DASHBOARDSINFO, API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS } from '../../common'; +import { httpGet, httpGetWithIgnores, httpPut } from '../apps/configuration/utils/request-utils'; import { DashboardsInfo } from '../types'; +import { DashboardSignInOptions } from '../apps/configuration/types'; export async function getDashboardsInfo(http: HttpStart) { return await httpGet(http, API_ENDPOINT_DASHBOARDSINFO); @@ -25,3 +26,16 @@ export async function getDashboardsInfo(http: HttpStart) { export async function getDashboardsInfoSafe(http: HttpStart): Promise { return httpGetWithIgnores(http, API_ENDPOINT_DASHBOARDSINFO, [401]); } + +export async function getDashboardsSignInOptions(http: HttpStart) { + return await httpGet(http, API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS); +} + +export async function updateDashboardSignInOptions( + http: HttpStart, + signInOptions: DashboardSignInOptions[] +) { + return await httpPut(http, API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS, { + sign_in_options: signInOptions, + }); +} diff --git a/server/auth/auth_handler_factory.ts b/server/auth/auth_handler_factory.ts index 2cd3c7c25..0613ae6cc 100644 --- a/server/auth/auth_handler_factory.ts +++ b/server/auth/auth_handler_factory.ts @@ -57,6 +57,7 @@ export async function getAuthenticationHandler( logger: Logger ): Promise { let authHandlerType: IAuthHandlerConstructor; + if (typeof authType === 'string' || authType.length === 1) { const currType = typeof authType === 'string' ? authType : authType[0]; switch (currType.toLowerCase()) { @@ -80,7 +81,7 @@ export async function getAuthenticationHandler( throw new Error(`Unsupported authentication type: ${currType}`); } } else { - if (config.auth.multiple_auth_enabled) { + if (authType.length > 1) { authHandlerType = MultipleAuthentication; } else { throw new Error( diff --git a/server/auth/types/basic/routes.ts b/server/auth/types/basic/routes.ts index bae3e338c..3b567df9e 100755 --- a/server/auth/types/basic/routes.ts +++ b/server/auth/types/basic/routes.ts @@ -29,8 +29,8 @@ import { LOGIN_PAGE_URI, } from '../../../../common'; import { resolveTenant } from '../../../multitenancy/tenant_resolver'; -import { encodeUriQuery } from '../../../../../../src/plugins/opensearch_dashboards_utils/common/url/encode_uri_query'; import { AuthType } from '../../../../common'; +import { DashboardSignInOptions } from '../../../../public/apps/configuration/types' export class BasicAuthRoutes { constructor( @@ -179,50 +179,50 @@ export class BasicAuthRoutes { let redirectUrl: string = this.coreSetup.http.basePath.serverBasePath ? this.coreSetup.http.basePath.serverBasePath : '/'; - const requestQuery = request.url.searchParams; - const nextUrl = requestQuery?.get('nextUrl'); - if (nextUrl) { - redirectUrl = nextUrl; - } - context.security_plugin.logger.info('The Redirect Path is ' + redirectUrl); - try { - user = await this.securityClient.authenticateWithHeaders(request, {}); - } catch (error) { - context.security_plugin.logger.error( - `Failed authentication: ${error}. Redirecting to Login Page` - ); - return response.redirected({ - headers: { - location: `${this.coreSetup.http.basePath.serverBasePath}${LOGIN_PAGE_URI}${ - nextUrl ? '?nextUrl=' + encodeUriQuery(redirectUrl) : '' - }`, - }, - }); - } - this.sessionStorageFactory.asScoped(request).clear(); - const sessionStorage: SecuritySessionCookie = { - username: user.username, - authType: AuthType.BASIC, - isAnonymousAuth: true, - expiryTime: Date.now() + this.config.session.ttl, - }; - - if (user.multitenancy_enabled) { - const selectTenant = resolveTenant({ - request, + const anonymousAccessAllowed = await verifyAnonymousAccess(this.securityClient, request); + + if (anonymousAccessAllowed) { + context.security_plugin.logger.info('The Redirect Path is ' + redirectUrl); + try { + user = await this.securityClient.authenticateWithHeaders(request, {}); + } catch (error) { + context.security_plugin.logger.error( + `Failed authentication: ${error}. Redirecting to Login Page` + ); + return response.redirected({ + headers: { + location: `${this.coreSetup.http.basePath.serverBasePath}${LOGIN_PAGE_URI}`, + }, + }); + } + + this.sessionStorageFactory.asScoped(request).clear(); + const sessionStorage: SecuritySessionCookie = { username: user.username, - roles: user.roles, - availableTenants: user.tenants, - config: this.config, - cookie: sessionStorage, - multitenancyEnabled: user.multitenancy_enabled, - privateTenantEnabled: user.private_tenant_enabled, - defaultTenant: user.default_tenant, - }); - sessionStorage.tenant = selectTenant; + authType: AuthType.BASIC, + isAnonymousAuth: true, + expiryTime: Date.now() + this.config.session.ttl, + }; + + if (user.multitenancy_enabled) { + const selectTenant = resolveTenant({ + request, + username: user.username, + roles: user.roles, + availableTenants: user.tenants, + config: this.config, + cookie: sessionStorage, + multitenancyEnabled: user.multitenancy_enabled, + privateTenantEnabled: user.private_tenant_enabled, + defaultTenant: user.default_tenant, + }); + sessionStorage.tenant = selectTenant; + } + this.sessionStorageFactory.asScoped(request).set(sessionStorage); + } else { + redirectUrl = LOGIN_PAGE_URI; } - this.sessionStorageFactory.asScoped(request).set(sessionStorage); return response.redirected({ headers: { @@ -243,3 +243,13 @@ export class BasicAuthRoutes { ); } } + +async function verifyAnonymousAccess(securityClient: SecurityClient, request: any) { + //Preventing auto login. + const isAutologin = request.url.href.includes("anonymous?"); + + const dashboardsInfo = await securityClient.dashboardsinfo(request); + const isAnonymousEnabled = dashboardsInfo.sign_in_options.includes(DashboardSignInOptions[DashboardSignInOptions.ANONYMOUS]); + + return (isAnonymousEnabled && !isAutologin) || (isAnonymousEnabled && dashboardsInfo.sign_in_options.length === 1); +} diff --git a/server/index.ts b/server/index.ts index 68a20f533..e00d39d25 100644 --- a/server/index.ts +++ b/server/index.ts @@ -271,7 +271,7 @@ export const configSchema = schema.object({ }), openid: schema.object({ login: schema.object({ - buttonname: schema.string({ defaultValue: 'Log in with single sign-on' }), + buttonname: schema.string({ defaultValue: 'Log in with single sign-on (OpenID)' }), showbrandimage: schema.boolean({ defaultValue: false }), brandimage: schema.string({ defaultValue: '' }), buttonstyle: schema.string({ defaultValue: '' }), @@ -279,7 +279,7 @@ export const configSchema = schema.object({ }), saml: schema.object({ login: schema.object({ - buttonname: schema.string({ defaultValue: 'Log in with single sign-on' }), + buttonname: schema.string({ defaultValue: 'Log in with single sign-on (SAML)' }), showbrandimage: schema.boolean({ defaultValue: false }), brandimage: schema.string({ defaultValue: '' }), buttonstyle: schema.string({ defaultValue: '' }), diff --git a/server/plugin.ts b/server/plugin.ts index 5f5f50913..6e9f5a7ae 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -46,6 +46,8 @@ import { createMigrationOpenSearchClient } from '../../../src/core/server/saved_ import { SecuritySavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper'; import { addTenantParameterToResolvedShortLink } from './multitenancy/tenant_resolver'; import { ReadonlyService } from './readonly/readonly_service'; +import { DashboardSignInOptions } from '../public/apps/configuration/types'; +import { AuthType } from '../common'; export interface SecurityPluginRequestContext { logger: Logger; @@ -113,8 +115,24 @@ export class SecurityPlugin implements Plugin + data.sign_in_options.map((opt: string) => { + if (DashboardSignInOptions[opt] === DashboardSignInOptions.BASIC) { + return AuthType.BASIC; + } else { + return opt.toString().toLowerCase(); + } + }) + ); + + // Combine sign in options with auth.type in case there are JWT, PROXY or more auth types. + dashboardSignInOptions = new Set([...dashboardSignInOptions, ...config.auth.type]); + dashboardSignInOptions = [...dashboardSignInOptions]; + const auth: IAuthenticationType = await getAuthenticationHandler( - config.auth.type, + dashboardSignInOptions, router, config, core, diff --git a/server/routes/index.ts b/server/routes/index.ts index 934c63da7..3ea21d9da 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -20,7 +20,13 @@ import { IOpenSearchDashboardsResponse, OpenSearchDashboardsResponseFactory, } from 'opensearch-dashboards/server'; -import { API_PREFIX, CONFIGURATION_API_PREFIX, isValidResourceName } from '../../common'; +import { + API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS, + API_PREFIX, + CONFIGURATION_API_PREFIX, + isValidResourceName, +} from '../../common'; +import { DashboardSignInOptions } from '../../public/apps/configuration/types'; import { ResourceType } from '../../common'; // TODO: consider to extract entity CRUD operations and put it into a client class @@ -574,6 +580,65 @@ export function defineRoutes(router: IRouter) { } ); + router.get( + { + path: `${API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS}`, + validate: false, + options: { + authRequired: false, + }, + }, + async ( + context, + request, + response + ): Promise> => { + const client = context.security_plugin.esClient.asScoped(request); + let esResp; + try { + esResp = await client.callAsInternalUser('opensearch_security.dashboardsinfo'); + return response.ok({ + body: esResp.sign_in_options, + }); + } catch (error) { + return errorResponse(response, error); + } + } + ); + + router.put( + { + path: `${API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS}`, + validate: { + body: schema.object({ + sign_in_options: schema.arrayOf(schema.any(), { + defaultValue: [DashboardSignInOptions.BASIC], + }), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const client = context.security_plugin.esClient.asScoped(request); + let esResp; + try { + esResp = await client.callAsCurrentUser('opensearch_security.tenancy_configs', { + body: request.body, + }); + return response.ok({ + body: { + message: esResp.message, + }, + }); + } catch (error) { + return errorResponse(response, error); + } + } + ); + /** * Gets audit log configuration。 * diff --git a/test/cypress/e2e/dashboard-signin-options.cy.spec.js b/test/cypress/e2e/dashboard-signin-options.cy.spec.js new file mode 100644 index 000000000..cfd7eb7dc --- /dev/null +++ b/test/cypress/e2e/dashboard-signin-options.cy.spec.js @@ -0,0 +1,95 @@ +/* + * 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 { ADMIN_AUTH } from '../support/constants'; + +function login() { + cy.visit('http://localhost:5601/app/login?nextUrl=%2Fapp%2Fsecurity-dashboards-plugin#/auth'); + cy.get('[data-testid="username"]').type(ADMIN_AUTH.username); + cy.get('[data-testid="password"]').type(ADMIN_AUTH.password); + cy.get('[data-testid="login"]').click(); + if (cy.contains('Cancel')) { + cy.contains('Cancel').click(); + } +} + +function logout() { + cy.get('.euiAvatar').click(); + cy.contains('Log out').click(); +} + +// OpenSearch backend must have BASIC and SAML authentication options for tests to work. +describe('Testing Dashboard SignIn Options', () => { + it('Dashboard Plugin Auth shows dashboard sign in options with their state.', () => { + login(); + cy.contains('BASIC'); + cy.contains('Enable'); + cy.contains('SAML'); + cy.contains('Disable'); + }); + + it('Login page shows Basic and Single Sign-On options.', () => { + login(); + + cy.get('[data-testid="edit"]').click(); + cy.get('#_selection_column_SAML-checkbox').check(); + cy.get('[data-testid="update"]').click(); + + logout(); + + cy.get('[data-testid="username"]').should('exist'); + cy.get('[data-testid="password"]').should('exist'); + cy.contains('Log in with single sign-on').should('exist'); + }); + + it('Login page shows only Single Sign-On option (SAML).', () => { + login(); + + cy.get('[data-testid="edit"]').click(); + cy.get('#_selection_column_SAML-checkbox').check(); + cy.get('#_selection_column_BASIC-checkbox').uncheck(); + cy.get('[data-testid="update"]').click(); + + logout(); + + cy.contains('Log in with single sign-on'); + cy.get('[data-testid="username"]').should('not.exist'); + cy.get('[data-testid="password"]').should('not.exist'); + }); + + it('Login page shows only Basic sign-in option.', () => { + cy.visit('http://localhost:5601/app/login?nextUrl=%2Fapp%2Fsecurity-dashboards-plugin#/auth'); + cy.contains('Log in with single sign-on').click(); + + cy.get('[name="username"]').type('user1'); + cy.get('[name="password"]').type('user1pass'); + cy.contains('Login').click(); + + if (cy.contains('Cancel')) { + cy.contains('Cancel').click(); + } + + cy.get('[data-testid="edit"]').click(); + cy.get('#_selection_column_SAML-checkbox').uncheck(); + cy.get('#_selection_column_BASIC-checkbox').check(); + cy.get('[data-testid="update"]').click(); + + cy.visit('http://localhost:5601/app/login'); + + cy.contains('Log in with single sign-on').should('not.exist'); + cy.get('[data-testid="username"]').should('exist'); + cy.get('[data-testid="password"]').should('exist'); + }); +}); diff --git a/test/jest_integration/get-dashboard-signin-options.test.ts b/test/jest_integration/get-dashboard-signin-options.test.ts new file mode 100644 index 000000000..9d5e10b75 --- /dev/null +++ b/test/jest_integration/get-dashboard-signin-options.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { Root } from '../../../../src/core/server/root'; +import * as osdTestServer from '../../../../src/core/test_helpers/osd_server'; +import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'; +import { resolve } from 'path'; + +import { + AUTHORIZATION_HEADER_NAME, + OPENSEARCH_DASHBOARDS_SERVER_PASSWORD, + OPENSEARCH_DASHBOARDS_SERVER_USER, +} from '../constant'; +import { API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS } from '../../common'; +import { DashboardSignInOptions } from '../../public/apps/configuration/types'; + +describe('start OpenSearch Dashboards server', () => { + let root: Root; + + beforeAll(async () => { + root = osdTestServer.createRootWithSettings( + { + plugins: { + scanDirs: [resolve(__dirname, '../..')], + }, + opensearch: { + hosts: ['https://localhost:9200'], + ignoreVersionMismatch: true, + ssl: { verificationMode: 'none' }, + username: OPENSEARCH_DASHBOARDS_SERVER_USER, + password: OPENSEARCH_DASHBOARDS_SERVER_PASSWORD, + }, + opensearch_security: { + multitenancy: { enabled: true, tenants: { preferred: ['Private', 'Global'] } }, + }, + }, + { + // to make ignoreVersionMismatch setting work + // can be removed when we have corresponding ES version + dev: true, + } + ); + + console.log('Starting OpenSearchDashboards server..'); + await root.setup(); + await root.start(); + console.log('Started OpenSearchDashboards server'); + }); + + afterAll(async () => { + // shutdown OpenSearchDashboards server + await root.shutdown(); + }); + + it(`get ${API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS} should return all dashboard sign-in options from backend.`, async () => { + const response = await osdTestServer.request + .get(root, API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS) + .unset(AUTHORIZATION_HEADER_NAME); + + expect(response.status).toEqual(200); + expect(response.text).toContain(DashboardSignInOptions[DashboardSignInOptions.BASIC]); + expect(JSON.parse(response.text)).toBeInstanceOf(Array); + }); +});