diff --git a/common/index.ts b/common/index.ts index 535d94972..028d9d376 100644 --- a/common/index.ts +++ b/common/index.ts @@ -18,16 +18,29 @@ export const PLUGIN_NAME = 'security-dashboards-plugin'; export const APP_ID_LOGIN = 'login'; export const APP_ID_CUSTOMERROR = 'customerror'; +export const OPENDISTRO_SECURITY_ANONYMOUS = 'opendistro_security_anonymous'; 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 CUSTOM_ERROR_PAGE_URI = '/app/' + APP_ID_CUSTOMERROR; 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'; + +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 ERROR_MISSING_ROLE_PATH = '/missing-role'; +export const AUTH_HEADER_NAME = 'authorization'; +export const AUTH_GRANT_TYPE = 'authorization_code'; +export const AUTH_RESPONSE_TYPE = 'code'; export const GLOBAL_TENANT_SYMBOL = ''; export const PRIVATE_TENANT_SYMBOL = '__user__'; @@ -42,6 +55,7 @@ export enum AuthType { JWT = 'jwt', SAML = 'saml', PROXY = 'proxy', + ANONYMOUS = 'anonymous', } /** diff --git a/public/apps/account/account-app.tsx b/public/apps/account/account-app.tsx index 590d827ea..ebcd15d5a 100644 --- a/public/apps/account/account-app.tsx +++ b/public/apps/account/account-app.tsx @@ -19,7 +19,7 @@ import { CoreStart } from 'opensearch-dashboards/public'; import { AccountNavButton } from './account-nav-button'; import { fetchAccountInfoSafe } from './utils'; import { ClientConfigType } from '../../types'; -import { CUSTOM_ERROR_PAGE_URI, ERROR_MISSING_ROLE_PATH } from '../../../common'; +import { AuthType, CUSTOM_ERROR_PAGE_URI, ERROR_MISSING_ROLE_PATH } from '../../../common'; import { fetchCurrentTenant, selectTenant } from '../configuration/utils/tenant-utils'; import { getSavedTenant, @@ -27,6 +27,7 @@ import { setShouldShowTenantPopup, } from '../../utils/storage-utils'; import { constructErrorMessageAndLog } from '../error-utils'; +import { fetchCurrentAuthType } from '../../utils/logout-utils'; function tenantSpecifiedInUrl() { return ( @@ -36,6 +37,20 @@ function tenantSpecifiedInUrl() { } export async function setupTopNavButton(coreStart: CoreStart, config: ClientConfigType) { + const authType = config.auth?.type; + let currAuthType = ''; + if (typeof authType === 'string') { + currAuthType = authType; + } else if (Array.isArray(authType) && authType.length === 1) { + currAuthType = authType[0]; + } else { + try { + currAuthType = (await fetchCurrentAuthType(coreStart.http))?.currentAuthType; + } catch (e) { + currAuthType = AuthType.BASIC; + } + } + const accountInfo = (await fetchAccountInfoSafe(coreStart.http))?.data; if (accountInfo) { // Missing role error @@ -94,6 +109,7 @@ export async function setupTopNavButton(coreStart: CoreStart, config: ClientConf username={accountInfo.user_name} tenant={tenant} config={config} + currAuthType={currAuthType.toLowerCase()} />, element ); diff --git a/public/apps/account/account-nav-button.tsx b/public/apps/account/account-nav-button.tsx index 1ca3360b1..001f69f02 100644 --- a/public/apps/account/account-nav-button.tsx +++ b/public/apps/account/account-nav-button.tsx @@ -42,6 +42,7 @@ export function AccountNavButton(props: { username: string; tenant?: string; config: ClientConfigType; + currAuthType: string; }) { const [isPopoverOpen, setPopoverOpen] = React.useState(false); const [modal, setModal] = React.useState(null); @@ -137,7 +138,7 @@ export function AccountNavButton(props: { )} {props.divider} @@ -32,13 +34,13 @@ export function LogoutButton(props: { data-test-subj="log-out-2" color="danger" size="xs" - href={`${props.http.basePath.serverBasePath}/auth/logout`} + onClick={() => externalLogout(props.http, OPENID_AUTH_LOGOUT)} > Log out ); - } else if (props.authType === 'saml') { + } else if (props.authType === AuthType.SAML) { return (
{props.divider} @@ -46,13 +48,14 @@ export function LogoutButton(props: { data-test-subj="log-out-1" color="danger" size="xs" - onClick={() => samlLogout(props.http)} + onClick={() => externalLogout(props.http, SAML_AUTH_LOGOUT)} > Log out
); - } else if (props.authType === 'proxy') { + } else if (props.authType === AuthType.PROXY) { + setShouldShowTenantPopup(null); return
; } else { return ( diff --git a/public/apps/account/test/__snapshots__/log-out-button.test.tsx.snap b/public/apps/account/test/__snapshots__/log-out-button.test.tsx.snap index c3c476b25..48432241e 100644 --- a/public/apps/account/test/__snapshots__/log-out-button.test.tsx.snap +++ b/public/apps/account/test/__snapshots__/log-out-button.test.tsx.snap @@ -1,11 +1,50 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Account menu - Log out button renders renders when auth type is MultiAuth: basicauth 1`] = ` +
+ + Log out + +
+`; + +exports[`Account menu - Log out button renders renders when auth type is MultiAuth: openid 1`] = ` +
+ + Log out + +
+`; + +exports[`Account menu - Log out button renders renders when auth type is MultiAuth: saml 1`] = ` +
+ + Log out + +
+`; + exports[`Account menu - Log out button renders renders when auth type is OpenId 1`] = `
Log out diff --git a/public/apps/account/test/account-app.test.tsx b/public/apps/account/test/account-app.test.tsx index 52282ab72..21e61d375 100644 --- a/public/apps/account/test/account-app.test.tsx +++ b/public/apps/account/test/account-app.test.tsx @@ -22,6 +22,7 @@ import { getSavedTenant, } from '../../../utils/storage-utils'; import { fetchAccountInfoSafe } from '../utils'; +import { fetchCurrentAuthType } from '../../../utils/logout-utils'; import { fetchCurrentTenant, selectTenant } from '../../configuration/utils/tenant-utils'; jest.mock('../../../utils/storage-utils', () => ({ @@ -34,6 +35,10 @@ jest.mock('../utils', () => ({ fetchAccountInfoSafe: jest.fn(), })); +jest.mock('../../../utils/logout-utils', () => ({ + fetchCurrentAuthType: jest.fn(), +})); + jest.mock('../../configuration/utils/tenant-utils', () => ({ selectTenant: jest.fn(), fetchCurrentTenant: jest.fn(), @@ -66,6 +71,7 @@ describe('Account app', () => { beforeAll(() => { (fetchAccountInfoSafe as jest.Mock).mockResolvedValue(mockAccountInfo); + (fetchCurrentAuthType as jest.Mock).mockResolvedValue('dummy'); (fetchCurrentTenant as jest.Mock).mockResolvedValue(mockTenant); }); diff --git a/public/apps/account/test/account-nav-button.test.tsx b/public/apps/account/test/account-nav-button.test.tsx index d68f00912..cd3127419 100644 --- a/public/apps/account/test/account-nav-button.test.tsx +++ b/public/apps/account/test/account-nav-button.test.tsx @@ -56,6 +56,7 @@ describe('Account navigation button', () => { username={userName} tenant="tenant1" config={config as any} + currAuthType={'dummy'} /> ); }); @@ -77,6 +78,7 @@ describe('Account navigation button', () => { username={userName} tenant="tenant1" config={config as any} + currAuthType={'dummy'} /> ); expect(setState).toBeCalledTimes(1); @@ -137,6 +139,7 @@ describe('Account navigation button, multitenancy disabled', () => { isInternalUser={true} username={userName} config={config as any} + currAuthType={'dummy'} /> ); expect(setState).toBeCalledTimes(0); diff --git a/public/apps/account/test/log-out-button.test.tsx b/public/apps/account/test/log-out-button.test.tsx index e8874c5ba..7fd45095a 100644 --- a/public/apps/account/test/log-out-button.test.tsx +++ b/public/apps/account/test/log-out-button.test.tsx @@ -27,7 +27,9 @@ describe('Account menu - Log out button', () => { OpenId = 'openid', SAML = 'saml', Proxy = 'proxy', + Basic = 'basicauth', } + const mockHttpStart = { basePath: { serverBasePath: '', @@ -35,6 +37,27 @@ describe('Account menu - Log out button', () => { }; const mockDivider = <>; describe('renders', () => { + it('renders when auth type is MultiAuth: openid', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + + it('renders when auth type is MultiAuth: saml', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + + it('renders when auth type is MultiAuth: basicauth', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); + it('renders when auth type is OpenId', () => { const component = shallow( diff --git a/public/apps/account/utils.tsx b/public/apps/account/utils.tsx index 637f700fa..180a2861a 100644 --- a/public/apps/account/utils.tsx +++ b/public/apps/account/utils.tsx @@ -14,7 +14,12 @@ */ import { HttpStart } from 'opensearch-dashboards/public'; -import { API_AUTH_LOGOUT, LOGIN_PAGE_URI } from '../../../common'; +import { + API_AUTH_LOGOUT, + LOGIN_PAGE_URI, + OPENID_AUTH_LOGOUT, + SAML_AUTH_LOGOUT, +} from '../../../common'; import { API_ENDPOINT_ACCOUNT_INFO } from './constants'; import { AccountInfo } from './types'; import { httpGet, httpGetWithIgnores, httpPost } from '../configuration/utils/request-utils'; @@ -43,7 +48,21 @@ export async function logout(http: HttpStart, logoutUrl?: string): Promise export async function samlLogout(http: HttpStart): Promise { // This will ensure tenancy is picked up from local storage in the next login. setShouldShowTenantPopup(null); - window.location.href = `${http.basePath.serverBasePath}${API_AUTH_LOGOUT}`; + window.location.href = `${http.basePath.serverBasePath}${SAML_AUTH_LOGOUT}`; +} + +export async function openidLogout(http: HttpStart): Promise { + // This will ensure tenancy is picked up from local storage in the next login. + setShouldShowTenantPopup(null); + sessionStorage.clear(); + window.location.href = `${http.basePath.serverBasePath}${OPENID_AUTH_LOGOUT}`; +} + +export async function externalLogout(http: HttpStart, logoutEndpoint: string): Promise { + // This will ensure tenancy is picked up from local storage in the next login. + setShouldShowTenantPopup(null); + sessionStorage.clear(); + window.location.href = `${http.basePath.serverBasePath}${logoutEndpoint}`; } export async function updateNewPassword( diff --git a/public/apps/login/login-app.tsx b/public/apps/login/login-app.tsx index 46ac1649d..77eaa8eac 100644 --- a/public/apps/login/login-app.tsx +++ b/public/apps/login/login-app.tsx @@ -24,7 +24,7 @@ import { ClientConfigType } from '../../types'; export function renderApp( coreStart: CoreStart, params: AppMountParameters, - config: ClientConfigType['ui']['basicauth']['login'] + config: ClientConfigType ) { ReactDOM.render(, params.element); return () => ReactDOM.unmountComponentAtNode(params.element); diff --git a/public/apps/login/login-page.tsx b/public/apps/login/login-page.tsx index 36cd04ae5..3179ad1be 100644 --- a/public/apps/login/login-page.tsx +++ b/public/apps/login/login-page.tsx @@ -24,15 +24,29 @@ import { EuiListGroup, EuiForm, EuiFormRow, + EuiHorizontalRule, } from '@elastic/eui'; import { CoreStart } from '../../../../../src/core/public'; import { ClientConfigType } from '../../types'; import defaultBrandImage from '../../assets/opensearch_logo_h.svg'; import { validateCurrentPassword } from '../../utils/login-utils'; +import { + ANONYMOUS_AUTH_LOGIN, + AuthType, + OPENID_AUTH_LOGIN, + SAML_AUTH_LOGIN_WITH_FRAGMENT, +} from '../../../common'; interface LoginPageDeps { http: CoreStart['http']; - config: ClientConfigType['ui']['basicauth']['login']; + config: ClientConfigType; +} + +interface LoginButtonConfig { + buttonname: string; + showbrandimage: boolean; + brandimage: string; + buttonstyle: string; } function redirect(serverBasePath: string) { @@ -51,14 +65,15 @@ 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 = null; + let errorLabel: any = null; if (loginFailed) { errorLabel = ( - Invalid username or password, please try again + {loginError} ); } @@ -89,60 +104,162 @@ export function LoginPage(props: LoginPageDeps) { } catch (error) { console.log(error); setloginFailed(true); + setloginError('Invalid username or password. Please try again.'); return; } }; + const renderLoginButton = ( + authType: string, + loginEndPoint: string, + buttonConfig: LoginButtonConfig + ) => { + const buttonId = `${authType}_login_button`; + return ( + + + {buttonConfig.buttonname} + + + ); + }; + + const formOptions = (options: string | string[]) => { + let formBody = []; + const formBodyOp = []; + let authOpts = []; + + 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); + } else { + authOpts = [...options]; + } + } + + for (let i = 0; i < authOpts.length; i++) { + switch (authOpts[i].toLowerCase()) { + case AuthType.BASIC: { + formBody.push( + + } + onChange={(e) => setUsername(e.target.value)} + value={username} + isInvalid={usernameValidationFailed} + /> + + ); + formBody.push( + + } + type="password" + onChange={(e) => setPassword(e.target.value)} + value={password} + isInvalid={usernameValidationFailed} + /> + + ); + const buttonId = `${AuthType.BASIC}_login_button`; + formBody.push( + + + Log in + + + ); + + 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: { + const oidcConfig = props.config.ui[AuthType.OPEN_ID].login; + formBodyOp.push(renderLoginButton(AuthType.OPEN_ID, OPENID_AUTH_LOGIN, oidcConfig)); + break; + } + case AuthType.SAML: { + const samlConfig = props.config.ui[AuthType.SAML].login; + formBodyOp.push( + renderLoginButton(AuthType.SAML, SAML_AUTH_LOGIN_WITH_FRAGMENT, samlConfig) + ); + break; + } + default: { + setloginFailed(true); + setloginError( + `Authentication Type: ${authOpts[i]} is not supported for multiple authentication.` + ); + break; + } + } + } + + formBody = formBody.concat(formBodyOp); + return formBody; + }; + // TODO: Get brand image from server config return ( - {props.config.showbrandimage && ( - + {props.config.ui.basicauth.login.showbrandimage && ( + )} - {props.config.title || 'Please login to OpenSearch Dashboards'} + {props.config.ui.basicauth.login.title || 'Log in to OpenSearch Dashboards'} - {props.config.subtitle || - 'If you have forgotten your username or password, please ask your system administrator'} + {props.config.ui.basicauth.login.subtitle || + 'If you have forgotten your username or password, contact your system administrator.'} - - } - onChange={(e) => setUsername(e.target.value)} - value={username} - isInvalid={usernameValidationFailed} - /> - - - } - type="password" - onChange={(e) => setPassword(e.target.value)} - value={password} - isInvalid={usernameValidationFailed} - /> - - - - Log In - - + {formOptions(props.config.auth.type)} {errorLabel} diff --git a/public/apps/login/test/__snapshots__/login-page.test.tsx.snap b/public/apps/login/test/__snapshots__/login-page.test.tsx.snap index baa453090..4381088ee 100644 --- a/public/apps/login/test/__snapshots__/login-page.test.tsx.snap +++ b/public/apps/login/test/__snapshots__/login-page.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Login page renders renders with config value 1`] = ` +exports[`Login page renders renders with config value for multiauth 1`] = ` @@ -42,6 +42,7 @@ exports[`Login page renders renders with config value 1`] = ` labelType="label" > - Log In + Log in + + + + + + + + Button1 + + + + + Button2 `; -exports[`Login page renders renders with default value 1`] = ` +exports[`Login page renders renders with config value: string 1`] = ` + + + + Title1 + + + + SubTitle1 + + + + + + } + value="" + /> + + + + } + type="password" + value="" + /> + + + + Log in + + + + +`; + +exports[`Login page renders renders with config value: string array 1`] = ` + + + + + Title1 + + + + SubTitle1 + + + + + + } + value="" + /> + + + + } + type="password" + value="" + /> + + + + Log in + + + + +`; + +exports[`Login page renders renders with default value: string 1`] = ` + + + + + Log in to OpenSearch Dashboards + + + + If you have forgotten your username or password, contact your system administrator. + + + + + + } + value="" + /> + + + + } + type="password" + value="" + /> + + + + Log in + + + + +`; + +exports[`Login page renders renders with default value: string array 1`] = ` + + @@ -111,7 +478,7 @@ exports[`Login page renders renders with default value 1`] = ` size="m" textAlign="center" > - Please login to OpenSearch Dashboards + Log in to OpenSearch Dashboards - If you have forgotten your username or password, please ask your system administrator + If you have forgotten your username or password, contact your system administrator. - Log In + Log in diff --git a/public/apps/login/test/login-page.test.tsx b/public/apps/login/test/login-page.test.tsx index dfbad54fe..b6988dbee 100644 --- a/public/apps/login/test/login-page.test.tsx +++ b/public/apps/login/test/login-page.test.tsx @@ -18,11 +18,50 @@ import React from 'react'; import { ClientConfigType } from '../../../types'; import { LoginPage } from '../login-page'; import { validateCurrentPassword } from '../../../utils/login-utils'; +import { API_AUTH_LOGOUT } from '../../../../common'; jest.mock('../../../utils/login-utils', () => ({ validateCurrentPassword: jest.fn(), })); +const configUI = { + basicauth: { + login: { + title: 'Title1', + subtitle: 'SubTitle1', + showbrandimage: true, + brandimage: 'http://localhost:5601/images/test.png', + buttonstyle: 'test-btn-style', + }, + }, + openid: { + login: { + buttonname: 'Button1', + showbrandimage: true, + brandimage: 'http://localhost:5601/images/test.png', + buttonstyle: 'test-btn-style', + }, + }, + saml: { + login: { + buttonname: 'Button2', + showbrandimage: true, + brandimage: 'http://localhost:5601/images/test.png', + buttonstyle: 'test-btn-style', + }, + }, + autologout: true, + backend_configurable: true, +}; + +const configUiDefault = { + basicauth: { + login: { + showbrandimage: true, + }, + }, +}; + describe('Login page', () => { const mockHttpStart = { basePath: { @@ -31,20 +70,61 @@ describe('Login page', () => { }; describe('renders', () => { - it('renders with config value', () => { - const config: ClientConfigType['ui']['basicauth']['login'] = { - title: 'Title1', - subtitle: 'SubTitle1', - showbrandimage: true, - brandimage: 'http://localhost:5601/images/test.png', - buttonstyle: 'test-btn-style', + it('renders with config value: string array', () => { + const config: ClientConfigType = { + ui: configUI, + auth: { + type: ['basicauth'], + logout_url: API_AUTH_LOGOUT, + }, + }; + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + it('renders with config value: string', () => { + const config: ClientConfigType = { + ui: configUI, + auth: { + type: 'basicauth', + logout_url: API_AUTH_LOGOUT, + }, + }; + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + it('renders with config value for multiauth', () => { + const config: ClientConfigType = { + ui: configUI, + auth: { + type: ['basicauth', 'openid', 'saml'], + logout_url: API_AUTH_LOGOUT, + }, }; const component = shallow(); expect(component).toMatchSnapshot(); }); - it('renders with default value', () => { - const component = shallow(); + it('renders with default value: string array', () => { + const config: ClientConfigType = { + ui: configUiDefault, + auth: { + type: [''], + }, + }; + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + it('renders with default value: string', () => { + const config: ClientConfigType = { + ui: configUiDefault, + auth: { + type: '', + }, + }; + const component = shallow(); expect(component).toMatchSnapshot(); }); }); @@ -53,10 +133,15 @@ describe('Login page', () => { let component; const setState = jest.fn(); const useState = jest.spyOn(React, 'useState'); - + const config: ClientConfigType = { + ui: configUiDefault, + auth: { + type: 'basicauth', + }, + }; beforeEach(() => { useState.mockImplementation((initialValue) => [initialValue, setState]); - component = shallow(); + component = shallow(); }); it('should update user name field on change event', () => { @@ -80,11 +165,16 @@ describe('Login page', () => { let component; const useState = jest.spyOn(React, 'useState'); const setState = jest.fn(); - + const config: ClientConfigType = { + ui: configUiDefault, + auth: { + type: 'basicauth', + }, + }; beforeEach(() => { useState.mockImplementation(() => ['user1', setState]); useState.mockImplementation(() => ['password1', setState]); - component = shallow(); + component = shallow(); }); it('submit click event', () => { diff --git a/public/plugin.ts b/public/plugin.ts index d8d39f5b7..4fa66aaf4 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -127,7 +127,7 @@ export class SecurityPlugin const { renderApp } = await import('./apps/login/login-app'); // @ts-ignore depsStart not used. const [coreStart, depsStart] = await core.getStartServices(); - return renderApp(coreStart, params, config.ui.basicauth.login); + return renderApp(coreStart, params, config); }, }); diff --git a/public/types.ts b/public/types.ts index 8dd2ac2c4..972ca244d 100644 --- a/public/types.ts +++ b/public/types.ts @@ -54,6 +54,30 @@ export interface ClientConfigType { buttonstyle: string; }; }; + anonymous: { + login: { + buttonname: 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; }; @@ -66,7 +90,8 @@ export interface ClientConfigType { }; }; auth: { - type: string; + type: string | string[]; + anonymous_auth_enabled: boolean; logout_url: string; }; clusterPermissions: { diff --git a/public/utils/logout-utils.tsx b/public/utils/logout-utils.tsx index 57c4adf2c..a0cb6529a 100644 --- a/public/utils/logout-utils.tsx +++ b/public/utils/logout-utils.tsx @@ -16,9 +16,11 @@ import { setShouldShowTenantPopup } from './storage-utils'; import { HttpInterceptorResponseError, + HttpStart, IHttpInterceptController, } from '../../../../src/core/public'; -import { CUSTOM_ERROR_PAGE_URI, LOGIN_PAGE_URI } from '../../common'; +import { CUSTOM_ERROR_PAGE_URI, LOGIN_PAGE_URI, API_ENDPOINT_AUTHTYPE } from '../../common'; +import { httpGet } from '../apps/configuration/utils/request-utils'; export function interceptError(logoutUrl: string, thisWindow: Window): any { return (httpErrorResponse: HttpInterceptorResponseError, _: IHttpInterceptController) => { @@ -41,3 +43,7 @@ export function interceptError(logoutUrl: string, thisWindow: Window): any { } }; } + +export async function fetchCurrentAuthType(http: HttpStart): Promise { + return await httpGet(http, API_ENDPOINT_AUTHTYPE); +} diff --git a/server/auth/auth_handler_factory.test.ts b/server/auth/auth_handler_factory.test.ts index 8c314e6d2..71d70ccac 100644 --- a/server/auth/auth_handler_factory.test.ts +++ b/server/auth/auth_handler_factory.test.ts @@ -13,7 +13,6 @@ * permissions and limitations under the License. */ -import { getAuthenticationHandler } from './auth_handler_factory'; import { IRouter, CoreSetup, @@ -23,6 +22,7 @@ import { } from '../../../../src/core/server'; import { SecurityPluginConfigType } from '..'; import { SecuritySessionCookie } from '../session/security_cookie'; +import { getAuthenticationHandler } from './auth_handler_factory'; jest.mock('./types', () => { return { @@ -30,30 +30,42 @@ jest.mock('./types', () => { return { authHandler: () => {}, type: 'basicauth', + init: () => {}, }; }), JwtAuthentication: jest.fn().mockImplementation(() => { return { authHandler: () => {}, type: 'jwt', + init: () => {}, }; }), OpenIdAuthentication: jest.fn().mockImplementation(() => { return { authHandler: () => {}, type: 'openid', + init: () => {}, }; }), ProxyAuthentication: jest.fn().mockImplementation(() => { return { authHandler: () => {}, type: 'proxy', + init: () => {}, }; }), SamlAuthentication: jest.fn().mockImplementation(() => { return { authHandler: () => {}, type: 'saml', + init: () => {}, + }; + }), + MultipleAuthentication: jest.fn().mockImplementation(() => { + return { + authHandler: () => {}, + type: ['openid', 'saml', 'basiauth'], + init: () => {}, }; }), }; @@ -69,8 +81,21 @@ describe('test authentication factory', () => { beforeEach(() => {}); - test('get basic auth', () => { - const auth = getAuthenticationHandler( + test('get basic auth: string array', async () => { + const auth = await getAuthenticationHandler( + ['basicauth'], + router, + config, + core, + esClient, + sessionStorageFactory, + logger + ); + expect(auth.type).toEqual('basicauth'); + }); + + test('get basic auth: string', async () => { + const auth = await getAuthenticationHandler( 'basicauth', router, config, @@ -82,8 +107,21 @@ describe('test authentication factory', () => { expect(auth.type).toEqual('basicauth'); }); - test('get basic auth with empty auth type', () => { - const auth = getAuthenticationHandler( + test('get basic auth with empty auth type: string array', async () => { + const auth = await getAuthenticationHandler( + [''], + router, + config, + core, + esClient, + sessionStorageFactory, + logger + ); + expect(auth.type).toEqual('basicauth'); + }); + + test('get basic auth with empty auth type: string', async () => { + const auth = await getAuthenticationHandler( '', router, config, @@ -95,8 +133,21 @@ describe('test authentication factory', () => { expect(auth.type).toEqual('basicauth'); }); - test('get jwt auth', () => { - const auth = getAuthenticationHandler( + test('get jwt auth: string array', async () => { + const auth = await getAuthenticationHandler( + ['jwt'], + router, + config, + core, + esClient, + sessionStorageFactory, + logger + ); + expect(auth.type).toEqual('jwt'); + }); + + test('get jwt auth: string', async () => { + const auth = await getAuthenticationHandler( 'jwt', router, config, @@ -108,8 +159,8 @@ describe('test authentication factory', () => { expect(auth.type).toEqual('jwt'); }); - test('get openid auth', () => { - const auth = getAuthenticationHandler( + test('get openid auth: string', async () => { + const auth = await getAuthenticationHandler( 'openid', router, config, @@ -121,8 +172,34 @@ describe('test authentication factory', () => { expect(auth.type).toEqual('openid'); }); - test('get proxy auth', () => { - const auth = getAuthenticationHandler( + test('get openid auth: string array', async () => { + const auth = await getAuthenticationHandler( + ['openid'], + router, + config, + core, + esClient, + sessionStorageFactory, + logger + ); + expect(auth.type).toEqual('openid'); + }); + + test('get proxy auth: string array', async () => { + const auth = await getAuthenticationHandler( + ['proxy'], + router, + config, + core, + esClient, + sessionStorageFactory, + logger + ); + expect(auth.type).toEqual('proxy'); + }); + + test('get proxy auth: string', async () => { + const auth = await getAuthenticationHandler( 'proxy', router, config, @@ -134,8 +211,21 @@ describe('test authentication factory', () => { expect(auth.type).toEqual('proxy'); }); - test('get saml auth', () => { - const auth = getAuthenticationHandler( + test('get saml auth: string array', async () => { + const auth = await getAuthenticationHandler( + ['saml'], + router, + config, + core, + esClient, + sessionStorageFactory, + logger + ); + expect(auth.type).toEqual('saml'); + }); + + test('get saml auth: string', async () => { + const auth = await getAuthenticationHandler( 'saml', router, config, @@ -147,9 +237,67 @@ describe('test authentication factory', () => { expect(auth.type).toEqual('saml'); }); - test('throws error for invalid auth type', () => { - expect(() => { - getAuthenticationHandler( + test('multiple_auth_enabled is on, get multi auth', async () => { + config = { + auth: { + multiple_auth_enabled: true, + }, + }; + const auth = await getAuthenticationHandler( + ['openid', 'saml', 'basiauth'], + router, + config, + core, + esClient, + sessionStorageFactory, + logger + ); + expect(auth.type).toEqual(['openid', 'saml', 'basiauth']); + }); + + test('multiple_auth_enabled is off, get multi auth', async () => { + config = { + auth: { + multiple_auth_enabled: false, + }, + }; + try { + await getAuthenticationHandler( + ['openid', 'saml', 'basiauth'], + router, + config, + core, + esClient, + sessionStorageFactory, + logger + ); + } catch (e) { + const targetError = + 'Error: Multiple Authentication Mode is disabled. To enable this feature, please set up opensearch_security.auth.multiple_auth_enabled: true'; + expect(e.toString()).toEqual(targetError); + } + }); + + test('throws error for invalid auth type: string array', async () => { + try { + await getAuthenticationHandler( + ['invalid'], + router, + config, + core, + esClient, + sessionStorageFactory, + logger + ); + } catch (e) { + const targetError = 'Error: Unsupported authentication type: invalid'; + expect(e.toString()).toEqual(targetError); + } + }); + + test('throws error for invalid auth type: string', async () => { + try { + await getAuthenticationHandler( 'invalid', router, config, @@ -158,6 +306,9 @@ describe('test authentication factory', () => { sessionStorageFactory, logger ); - }).toThrow('Unsupported authentication type: invalid'); + } catch (e) { + const targetError = 'Error: Unsupported authentication type: invalid'; + expect(e.toString()).toEqual(targetError); + } }); }); diff --git a/server/auth/auth_handler_factory.ts b/server/auth/auth_handler_factory.ts index 40ec299c2..2cd3c7c25 100644 --- a/server/auth/auth_handler_factory.ts +++ b/server/auth/auth_handler_factory.ts @@ -27,12 +27,13 @@ import { OpenIdAuthentication, ProxyAuthentication, SamlAuthentication, + MultipleAuthentication, } from './types'; import { SecuritySessionCookie } from '../session/security_cookie'; import { IAuthenticationType, IAuthHandlerConstructor } from './types/authentication_type'; import { SecurityPluginConfigType } from '..'; -function createAuthentication( +async function createAuthentication( ctor: IAuthHandlerConstructor, config: SecurityPluginConfigType, sessionStorageFactory: SessionStorageFactory, @@ -40,41 +41,54 @@ function createAuthentication( esClient: ILegacyClusterClient, coreSetup: CoreSetup, logger: Logger -): IAuthenticationType { - return new ctor(config, sessionStorageFactory, router, esClient, coreSetup, logger); +): Promise { + const authHandler = new ctor(config, sessionStorageFactory, router, esClient, coreSetup, logger); + await authHandler.init(); + return authHandler; } -export function getAuthenticationHandler( - authType: string, +export async function getAuthenticationHandler( + authType: string | string[], router: IRouter, config: SecurityPluginConfigType, core: CoreSetup, esClient: ILegacyClusterClient, securitySessionStorageFactory: SessionStorageFactory, logger: Logger -): IAuthenticationType { +): Promise { let authHandlerType: IAuthHandlerConstructor; - switch (authType) { - case '': - case 'basicauth': - authHandlerType = BasicAuthentication; - break; - case AuthType.JWT: - authHandlerType = JwtAuthentication; - break; - case AuthType.OPEN_ID: - authHandlerType = OpenIdAuthentication; - break; - case AuthType.SAML: - authHandlerType = SamlAuthentication; - break; - case AuthType.PROXY: - authHandlerType = ProxyAuthentication; - break; - default: - throw new Error(`Unsupported authentication type: ${authType}`); + if (typeof authType === 'string' || authType.length === 1) { + const currType = typeof authType === 'string' ? authType : authType[0]; + switch (currType.toLowerCase()) { + case '': + case AuthType.BASIC: + authHandlerType = BasicAuthentication; + break; + case AuthType.JWT: + authHandlerType = JwtAuthentication; + break; + case AuthType.OPEN_ID: + authHandlerType = OpenIdAuthentication; + break; + case AuthType.SAML: + authHandlerType = SamlAuthentication; + break; + case AuthType.PROXY: + authHandlerType = ProxyAuthentication; + break; + default: + throw new Error(`Unsupported authentication type: ${currType}`); + } + } else { + if (config.auth.multiple_auth_enabled) { + authHandlerType = MultipleAuthentication; + } else { + throw new Error( + `Multiple Authentication Mode is disabled. To enable this feature, please set up opensearch_security.auth.multiple_auth_enabled: true` + ); + } } - const auth: IAuthenticationType = createAuthentication( + const auth: IAuthenticationType = await createAuthentication( authHandlerType, config, securitySessionStorageFactory, diff --git a/server/auth/types/authentication_type.ts b/server/auth/types/authentication_type.ts index b097435b8..b1ea1a208 100755 --- a/server/auth/types/authentication_type.ts +++ b/server/auth/types/authentication_type.ts @@ -36,6 +36,7 @@ import { GLOBAL_TENANT_SYMBOL } from '../../../common'; export interface IAuthenticationType { type: string; authHandler: AuthenticationHandler; + init: () => Promise; } export type IAuthHandlerConstructor = new ( @@ -114,7 +115,7 @@ export abstract class AuthenticationType implements IAuthenticationType { const additonalAuthHeader = this.getAdditionalAuthHeader(request); Object.assign(authHeaders, additonalAuthHeader); authInfo = await this.securityClient.authinfo(request, additonalAuthHeader); - cookie = await this.getCookie(request, authInfo); + cookie = this.getCookie(request, authInfo); // set tenant from cookie if exist const browserCookie = await this.sessionStorageFactory.asScoped(request).get(); @@ -262,17 +263,18 @@ export abstract class AuthenticationType implements IAuthenticationType { } // abstract functions for concrete auth types to implement - protected abstract requestIncludesAuthInfo(request: OpenSearchDashboardsRequest): boolean; - protected abstract getAdditionalAuthHeader(request: OpenSearchDashboardsRequest): any; - protected abstract getCookie( + public abstract requestIncludesAuthInfo(request: OpenSearchDashboardsRequest): boolean; + public abstract getAdditionalAuthHeader(request: OpenSearchDashboardsRequest): Promise; + public abstract getCookie( request: OpenSearchDashboardsRequest, authInfo: any ): SecuritySessionCookie; - protected abstract async isValidCookie(cookie: SecuritySessionCookie): Promise; + public abstract isValidCookie(cookie: SecuritySessionCookie): Promise; protected abstract handleUnauthedRequest( request: OpenSearchDashboardsRequest, response: LifecycleResponseFactory, toolkit: AuthToolkit ): IOpenSearchDashboardsResponse | AuthResult; - protected abstract buildAuthHeaderFromCookie(cookie: SecuritySessionCookie): any; + public abstract buildAuthHeaderFromCookie(cookie: SecuritySessionCookie): any; + public abstract init(): Promise; } diff --git a/server/auth/types/basic/basic_auth.ts b/server/auth/types/basic/basic_auth.ts index 066602737..af7a8727f 100644 --- a/server/auth/types/basic/basic_auth.ts +++ b/server/auth/types/basic/basic_auth.ts @@ -28,12 +28,12 @@ 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 { LOGIN_PAGE_URI, ANONYMOUS_AUTH_LOGIN } from '../../../../common'; import { composeNextUrlQueryParam } from '../../../utils/next_url'; +import { AUTH_HEADER_NAME, AuthType, OPENDISTRO_SECURITY_ANONYMOUS } from '../../../../common'; export class BasicAuthentication extends AuthenticationType { - private static readonly AUTH_HEADER_NAME: string = 'authorization'; - public readonly type: string = 'basicauth'; + public readonly type: string = AuthType.BASIC; constructor( config: SecurityPluginConfigType, @@ -44,11 +44,9 @@ export class BasicAuthentication extends AuthenticationType { logger: Logger ) { super(config, sessionStorageFactory, router, esClient, coreSetup, logger); - - this.init(); } - private async init() { + public async init() { const routes = new BasicAuthRoutes( this.router, this.config, @@ -63,17 +61,19 @@ export class BasicAuthentication extends AuthenticationType { requestIncludesAuthInfo( request: OpenSearchDashboardsRequest ): boolean { - return request.headers[BasicAuthentication.AUTH_HEADER_NAME] ? true : false; + return request.headers[AUTH_HEADER_NAME] ? true : false; } - getAdditionalAuthHeader(request: OpenSearchDashboardsRequest) { + async getAdditionalAuthHeader( + request: OpenSearchDashboardsRequest + ): Promise { return {}; } getCookie(request: OpenSearchDashboardsRequest, authInfo: any): SecuritySessionCookie { if ( this.config.auth.anonymous_auth_enabled && - authInfo.user_name === 'opendistro_security_anonymous' + authInfo.user_name === OPENDISTRO_SECURITY_ANONYMOUS ) { return { username: authInfo.user_name, @@ -85,7 +85,7 @@ export class BasicAuthentication extends AuthenticationType { return { username: authInfo.user_name, credentials: { - authHeaderValue: request.headers[BasicAuthentication.AUTH_HEADER_NAME], + authHeaderValue: request.headers[AUTH_HEADER_NAME], }, authType: this.type, expiryTime: Date.now() + this.config.session.ttl, @@ -112,7 +112,7 @@ export class BasicAuthentication extends AuthenticationType { this.coreSetup.http.basePath.serverBasePath ); if (this.config.auth.anonymous_auth_enabled) { - const redirectLocation = `${this.coreSetup.http.basePath.serverBasePath}/auth/anonymous?${nextUrlParam}`; + const redirectLocation = `${this.coreSetup.http.basePath.serverBasePath}${ANONYMOUS_AUTH_LOGIN}?${nextUrlParam}`; return response.redirected({ headers: { location: `${redirectLocation}`, diff --git a/server/auth/types/basic/routes.ts b/server/auth/types/basic/routes.ts index 07fa82761..a05f3878e 100755 --- a/server/auth/types/basic/routes.ts +++ b/server/auth/types/basic/routes.ts @@ -22,7 +22,12 @@ import { import { SecurityPluginConfigType } from '../../..'; import { User } from '../../user'; import { SecurityClient } from '../../../backend/opensearch_security_client'; -import { API_AUTH_LOGIN, API_AUTH_LOGOUT, LOGIN_PAGE_URI } from '../../../../common'; +import { + ANONYMOUS_AUTH_LOGIN, + API_AUTH_LOGIN, + API_AUTH_LOGOUT, + LOGIN_PAGE_URI, +} from '../../../../common'; import { resolveTenant } from '../../../multitenancy/tenant_resolver'; import { encodeUriQuery } from '../../../../../../src/plugins/opensearch_dashboards_utils/common/url/encode_uri_query'; @@ -89,7 +94,7 @@ export class BasicAuthRoutes { username: request.body.username, password: request.body.password, }); - } catch (error) { + } catch (error: any) { context.security_plugin.logger.error(`Failed authentication: ${error}`); return response.unauthorized({ headers: { @@ -124,7 +129,6 @@ export class BasicAuthRoutes { sessionStorage.tenant = selectTenant; } this.sessionStorageFactory.asScoped(request).set(sessionStorage); - return response.ok({ body: { username: user.username, @@ -157,7 +161,7 @@ export class BasicAuthRoutes { // anonymous auth this.router.get( { - path: `/auth/anonymous`, + path: ANONYMOUS_AUTH_LOGIN, validate: false, options: { authRequired: false, diff --git a/server/auth/types/index.ts b/server/auth/types/index.ts index 97df71704..2635a1923 100644 --- a/server/auth/types/index.ts +++ b/server/auth/types/index.ts @@ -18,3 +18,4 @@ export { JwtAuthentication } from './jwt/jwt_auth'; export { OpenIdAuthentication } from './openid/openid_auth'; export { ProxyAuthentication } from './proxy/proxy_auth'; export { SamlAuthentication } from './saml/saml_auth'; +export { MultipleAuthentication } from './multiple/multi_auth'; diff --git a/server/auth/types/jwt/jwt_auth.test.ts b/server/auth/types/jwt/jwt_auth.test.ts index 01b7b8bd8..c8fca618d 100644 --- a/server/auth/types/jwt/jwt_auth.test.ts +++ b/server/auth/types/jwt/jwt_auth.test.ts @@ -34,14 +34,14 @@ describe('test jwt auth library', () => { ); } - test('test getTokenFromUrlParam', () => { + test('test getTokenFromUrlParam', async () => { const config = { jwt: { header: 'Authorization', url_param: 'authorization', }, }; - const auth = getTestJWTAuthenticationHandlerWithConfig(config); + const auth = await getTestJWTAuthenticationHandlerWithConfig(config); const url = new URL('http://localhost:5601/app/api/v1/auth/authinfo?authorization=testtoken'); const request = { @@ -53,14 +53,14 @@ describe('test jwt auth library', () => { expect(token).toEqual(expectedToken); }); - test('test getTokenFromUrlParam incorrect url_param', () => { + test('test getTokenFromUrlParam incorrect url_param', async () => { const config = { jwt: { header: 'Authorization', url_param: 'urlParamName', }, }; - const auth = getTestJWTAuthenticationHandlerWithConfig(config); + const auth = await getTestJWTAuthenticationHandlerWithConfig(config); const url = new URL('http://localhost:5601/app/api/v1/auth/authinfo?authorization=testtoken'); const request = { diff --git a/server/auth/types/jwt/jwt_auth.ts b/server/auth/types/jwt/jwt_auth.ts index 878946adc..3b8ef365a 100644 --- a/server/auth/types/jwt/jwt_auth.ts +++ b/server/auth/types/jwt/jwt_auth.ts @@ -45,11 +45,9 @@ export class JwtAuthentication extends AuthenticationType { ) { super(config, sessionStorageFactory, router, esClient, coreSetup, logger); this.authHeaderName = this.config.jwt?.header.toLowerCase() || 'authorization'; - - this.init(); } - private async init() { + public async init() { const routes = new JwtAuthRoutes(this.router, this.sessionStorageFactory); routes.setupRoutes(); } @@ -73,7 +71,7 @@ export class JwtAuthentication extends AuthenticationType { return (request.headers[this.authHeaderName] as string) || undefined; } - protected requestIncludesAuthInfo( + requestIncludesAuthInfo( request: OpenSearchDashboardsRequest ): boolean { if (request.headers[this.authHeaderName]) { @@ -87,9 +85,9 @@ export class JwtAuthentication extends AuthenticationType { return false; } - protected getAdditionalAuthHeader( + async getAdditionalAuthHeader( request: OpenSearchDashboardsRequest - ) { + ): Promise { const header: any = {}; const token = this.getTokenFromUrlParam(request); if (token) { @@ -98,7 +96,7 @@ export class JwtAuthentication extends AuthenticationType { return header; } - protected getCookie( + getCookie( request: OpenSearchDashboardsRequest, authInfo: any ): SecuritySessionCookie { diff --git a/server/auth/types/multiple/multi_auth.ts b/server/auth/types/multiple/multi_auth.ts new file mode 100644 index 000000000..d119c18d6 --- /dev/null +++ b/server/auth/types/multiple/multi_auth.ts @@ -0,0 +1,180 @@ +/* + * 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, + ILegacyClusterClient, + OpenSearchDashboardsRequest, + Logger, + LifecycleResponseFactory, + AuthToolkit, +} from '../../../../opensearch-dashboards/server'; +import { OpenSearchDashboardsResponse } from '../../../../../../src/core/server/http/router'; +import { SecurityPluginConfigType } from '../../..'; +import { AuthenticationType, IAuthenticationType } from '../authentication_type'; +import { ANONYMOUS_AUTH_LOGIN, AuthType, LOGIN_PAGE_URI } from '../../../../common'; +import { composeNextUrlQueryParam } from '../../../utils/next_url'; +import { MultiAuthRoutes } from './routes'; +import { SecuritySessionCookie } from '../../../session/security_cookie'; +import { BasicAuthentication, OpenIdAuthentication, SamlAuthentication } from '../../types'; + +export class MultipleAuthentication extends AuthenticationType { + private authTypes: string | string[]; + private authHandlers: Map; + + constructor( + config: SecurityPluginConfigType, + sessionStorageFactory: SessionStorageFactory, + router: IRouter, + esClient: ILegacyClusterClient, + coreSetup: CoreSetup, + logger: Logger + ) { + super(config, sessionStorageFactory, router, esClient, coreSetup, logger); + this.authTypes = this.config.auth.type; + this.authHandlers = new Map(); + } + + public async init() { + const routes = new MultiAuthRoutes(this.router, this.sessionStorageFactory); + routes.setupRoutes(); + + for (let i = 0; i < this.authTypes.length; i++) { + switch (this.authTypes[i].toLowerCase()) { + case AuthType.BASIC: { + const BasicAuth = new BasicAuthentication( + this.config, + this.sessionStorageFactory, + this.router, + this.esClient, + this.coreSetup, + this.logger + ); + await BasicAuth.init(); + this.authHandlers.set(AuthType.BASIC, BasicAuth); + break; + } + case AuthType.OPEN_ID: { + const OidcAuth = new OpenIdAuthentication( + this.config, + this.sessionStorageFactory, + this.router, + this.esClient, + this.coreSetup, + this.logger + ); + await OidcAuth.init(); + this.authHandlers.set(AuthType.OPEN_ID, OidcAuth); + break; + } + case AuthType.SAML: { + const SamlAuth = new SamlAuthentication( + this.config, + this.sessionStorageFactory, + this.router, + this.esClient, + this.coreSetup, + this.logger + ); + await SamlAuth.init(); + this.authHandlers.set(AuthType.SAML, SamlAuth); + break; + } + default: { + throw new Error(`Unsupported authentication type: ${this.authTypes[i]}`); + } + } + } + } + + // 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 + ); + + if (this.config.auth.anonymous_auth_enabled) { + const redirectLocation = `${this.coreSetup.http.basePath.serverBasePath}${ANONYMOUS_AUTH_LOGIN}?${nextUrlParam}`; + return response.redirected({ + headers: { + location: `${redirectLocation}`, + }, + }); + } + 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/server/auth/types/multiple/routes.ts b/server/auth/types/multiple/routes.ts new file mode 100644 index 000000000..5ed6a9307 --- /dev/null +++ b/server/auth/types/multiple/routes.ts @@ -0,0 +1,47 @@ +/* + * 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 '../../../../../../src/core/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/server/auth/types/openid/openid_auth.ts b/server/auth/types/openid/openid_auth.ts index 1fb0f3d00..7f5d498df 100644 --- a/server/auth/types/openid/openid_auth.ts +++ b/server/auth/types/openid/openid_auth.ts @@ -36,6 +36,7 @@ import { AuthenticationType } from '../authentication_type'; import { callTokenEndpoint } from './helper'; import { composeNextUrlQueryParam } from '../../../utils/next_url'; import { getExpirationDate } from './helper'; +import { AuthType, OPENID_AUTH_LOGIN } from '../../../../common'; export interface OpenIdAuthConfig { authorizationEndpoint?: string; @@ -52,7 +53,7 @@ export interface WreckHttpsOptions { } export class OpenIdAuthentication extends AuthenticationType { - public readonly type: string = 'openid'; + public readonly type: string = AuthType.OPEN_ID; private openIdAuthConfig: OpenIdAuthConfig; private authHeaderName: string; @@ -81,11 +82,9 @@ export class OpenIdAuthentication extends AuthenticationType { scope = `openid ${scope}`; } this.openIdAuthConfig.scope = scope; - - this.init(); } - private async init() { + public async init() { try { const response = await this.wreckClient.get(this.openIdConnectUrl); const payload = JSON.parse(response.payload as string); @@ -104,7 +103,7 @@ export class OpenIdAuthentication extends AuthenticationType { this.wreckClient ); routes.setupRoutes(); - } catch (error) { + } catch (error: any) { this.logger.error(error); // TODO: log more info throw new Error('Failed when trying to obtain the endpoints from your IdP'); } @@ -140,7 +139,7 @@ export class OpenIdAuthentication extends AuthenticationType { return request.headers.authorization ? true : false; } - getAdditionalAuthHeader(request: OpenSearchDashboardsRequest): any { + async getAdditionalAuthHeader(request: OpenSearchDashboardsRequest): Promise { return {}; } @@ -196,7 +195,7 @@ export class OpenIdAuthentication extends AuthenticationType { } else { return false; } - } catch (error) { + } catch (error: any) { this.logger.error(error); return false; } @@ -219,7 +218,7 @@ export class OpenIdAuthentication extends AuthenticationType { ); return response.redirected({ headers: { - location: `${this.coreSetup.http.basePath.serverBasePath}/auth/openid/login?${nextUrl}`, + location: `${this.coreSetup.http.basePath.serverBasePath}${OPENID_AUTH_LOGIN}?${nextUrl}`, }, }); } else { diff --git a/server/auth/types/openid/routes.ts b/server/auth/types/openid/routes.ts index 627170f97..442b44e32 100644 --- a/server/auth/types/openid/routes.ts +++ b/server/auth/types/openid/routes.ts @@ -30,6 +30,14 @@ import { SecurityClient } from '../../../backend/opensearch_security_client'; import { getBaseRedirectUrl, callTokenEndpoint, composeLogoutUrl } from './helper'; import { validateNextUrl } from '../../../utils/next_url'; import { getExpirationDate } from './helper'; +import { + AuthType, + OPENID_AUTH_LOGIN, + AUTH_GRANT_TYPE, + AUTH_RESPONSE_TYPE, + OPENID_AUTH_LOGOUT, + LOGIN_PAGE_URI, +} from '../../../../common'; export class OpenIdAuthRoutes { private static readonly NONCE_LENGTH: number = 22; @@ -51,7 +59,7 @@ export class OpenIdAuthRoutes { this.sessionStorageFactory.asScoped(request).clear(); return response.redirected({ headers: { - location: `${this.core.http.basePath.serverBasePath}/auth/openid/login`, + location: `${this.core.http.basePath.serverBasePath}${OPENID_AUTH_LOGIN}`, }, }); } @@ -59,7 +67,7 @@ export class OpenIdAuthRoutes { public setupRoutes() { this.router.get( { - path: `/auth/openid/login`, + path: OPENID_AUTH_LOGIN, validate: { query: schema.object( { @@ -83,22 +91,20 @@ export class OpenIdAuthRoutes { }, async (context, request, response) => { // implementation refers to https://github.com/hapijs/bell/blob/master/lib/oauth.js - // Sign-in initialization if (!request.query.code) { const nonce = randomString(OpenIdAuthRoutes.NONCE_LENGTH); const query: any = { client_id: this.config.openid?.client_id, - response_type: 'code', + response_type: AUTH_RESPONSE_TYPE, redirect_uri: `${getBaseRedirectUrl( this.config, this.core, request - )}/auth/openid/login`, + )}${OPENID_AUTH_LOGIN}`, state: nonce, scope: this.openIdAuthConfig.scope, }; - const queryString = stringify(query); const location = `${this.openIdAuthConfig.authorizationEndpoint}?${queryString}`; const cookie: SecuritySessionCookie = { @@ -106,6 +112,7 @@ export class OpenIdAuthRoutes { state: nonce, nextUrl: request.query.nextUrl || '/', }, + authType: AuthType.OPEN_ID, }; this.sessionStorageFactory.asScoped(request).set(cookie); return response.redirected({ @@ -116,7 +123,6 @@ export class OpenIdAuthRoutes { } // Authentication callback - // validate state first let cookie; try { @@ -132,13 +138,16 @@ export class OpenIdAuthRoutes { return this.redirectToLogin(request, response); } const nextUrl: string = cookie.oidc.nextUrl; - const clientId = this.config.openid?.client_id; const clientSecret = this.config.openid?.client_secret; const query: any = { - grant_type: 'authorization_code', + grant_type: AUTH_GRANT_TYPE, code: request.query.code, - redirect_uri: `${getBaseRedirectUrl(this.config, this.core, request)}/auth/openid/login`, + redirect_uri: `${getBaseRedirectUrl( + this.config, + this.core, + request + )}${OPENID_AUTH_LOGIN}`, client_id: clientId, client_secret: clientSecret, }; @@ -162,7 +171,7 @@ export class OpenIdAuthRoutes { authHeaderValue: `Bearer ${tokenResponse.idToken}`, expires_at: getExpirationDate(tokenResponse), }, - authType: 'openid', + authType: AuthType.OPEN_ID, expiryTime: Date.now() + this.config.session.ttl, }; if (this.config.openid?.refresh_tokens && tokenResponse.refreshToken) { @@ -176,7 +185,7 @@ export class OpenIdAuthRoutes { location: nextUrl, }, }); - } catch (error) { + } catch (error: any) { context.security_plugin.logger.error(`OpenId authentication failed: ${error}`); if (error.toString().toLowerCase().includes('authentication exception')) { return response.unauthorized(); @@ -189,7 +198,7 @@ export class OpenIdAuthRoutes { this.router.get( { - path: `/auth/logout`, + path: OPENID_AUTH_LOGOUT, validate: false, }, async (context, request, response) => { @@ -198,11 +207,11 @@ export class OpenIdAuthRoutes { // 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: getBaseRedirectUrl(this.config, this.core, request), + post_logout_redirect_uri: `${nextUrl}`, id_token_hint: token, }; - const endSessionUrl = composeLogoutUrl( this.config.openid?.logout_url, this.openIdAuthConfig.endSessionEndpoint, diff --git a/server/auth/types/proxy/proxy_auth.ts b/server/auth/types/proxy/proxy_auth.ts index a6c5704af..346553a50 100644 --- a/server/auth/types/proxy/proxy_auth.ts +++ b/server/auth/types/proxy/proxy_auth.ts @@ -54,11 +54,9 @@ export class ProxyAuthentication extends AuthenticationType { this.userHeaderName = this.config.proxycache?.user_header?.toLowerCase() || ''; this.roleHeaderName = this.config.proxycache?.roles_header?.toLowerCase() || ''; - - this.setupRoutes(); } - private setupRoutes() { + public async init() { const routes = new ProxyAuthRoutes( this.router, this.config, @@ -75,7 +73,7 @@ export class ProxyAuthentication extends AuthenticationType { : false; } - getAdditionalAuthHeader(request: OpenSearchDashboardsRequest): any { + async getAdditionalAuthHeader(request: OpenSearchDashboardsRequest): Promise { const authHeaders: any = {}; const customProxyHeader = this.config.proxycache?.proxy_header; if ( diff --git a/server/auth/types/saml/routes.ts b/server/auth/types/saml/routes.ts index 79454272c..227976612 100644 --- a/server/auth/types/saml/routes.ts +++ b/server/auth/types/saml/routes.ts @@ -24,6 +24,7 @@ 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'; export class SamlAuthRoutes { constructor( @@ -38,7 +39,7 @@ export class SamlAuthRoutes { public setupRoutes() { this.router.get( { - path: `/auth/saml/login`, + path: SAML_AUTH_LOGIN, validate: { query: schema.object({ nextUrl: schema.maybe( @@ -64,6 +65,7 @@ export class SamlAuthRoutes { try { const samlHeader = await this.securityClient.getSamlHeader(request); + // const { nextUrl = '/' } = request.query; const cookie: SecuritySessionCookie = { saml: { nextUrl: request.query.nextUrl, @@ -135,6 +137,7 @@ export class SamlAuthRoutes { 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; } @@ -143,7 +146,7 @@ export class SamlAuthRoutes { credentials: { authHeaderValue: credentials.authorization, }, - authType: 'saml', // TODO: create constant + authType: AuthType.SAML, // TODO: create constant expiryTime, }; this.sessionStorageFactory.asScoped(request).set(cookie); @@ -211,7 +214,7 @@ export class SamlAuthRoutes { credentials: { authHeaderValue: credentials.authorization, }, - authType: 'saml', // TODO: create constant + authType: AuthType.SAML, // TODO: create constant expiryTime, }; this.sessionStorageFactory.asScoped(request).set(cookie); @@ -285,7 +288,6 @@ export class SamlAuthRoutes { finalUrl = "login?nextUrl=" + encodeURIComponent(nextUrl); finalUrl += "&redirectHash=" + encodeURIComponent(redirectHash); window.location.replace(finalUrl); - `, }); } @@ -344,7 +346,7 @@ export class SamlAuthRoutes { this.router.get( { - path: `/auth/logout`, + path: SAML_AUTH_LOGOUT, validate: false, }, async (context, request, response) => { diff --git a/server/auth/types/saml/saml_auth.ts b/server/auth/types/saml/saml_auth.ts index ee8762406..4f6e8b4f9 100644 --- a/server/auth/types/saml/saml_auth.ts +++ b/server/auth/types/saml/saml_auth.ts @@ -33,6 +33,7 @@ import { } from '../../../session/security_cookie'; import { SamlAuthRoutes } from './routes'; import { AuthenticationType } from '../authentication_type'; +import { AuthType } from '../../../../common'; export class SamlAuthentication extends AuthenticationType { public static readonly AUTH_HEADER_NAME = 'authorization'; @@ -48,7 +49,6 @@ export class SamlAuthentication extends AuthenticationType { logger: Logger ) { super(config, sessionStorageFactory, router, esClient, coreSetup, logger); - this.setupRoutes(); } private generateNextUrl(request: OpenSearchDashboardsRequest): string { @@ -68,7 +68,7 @@ export class SamlAuthentication extends AuthenticationType { }); }; - private setupRoutes(): void { + public async init() { const samlAuthRoutes = new SamlAuthRoutes( this.router, this.config, @@ -83,7 +83,7 @@ export class SamlAuthentication extends AuthenticationType { return request.headers[SamlAuthentication.AUTH_HEADER_NAME] ? true : false; } - getAdditionalAuthHeader(request: OpenSearchDashboardsRequest): any { + async getAdditionalAuthHeader(request: OpenSearchDashboardsRequest): Promise { return {}; } @@ -93,7 +93,7 @@ export class SamlAuthentication extends AuthenticationType { credentials: { authHeaderValue: request.headers[SamlAuthentication.AUTH_HEADER_NAME], }, - authType: this.type, + authType: AuthType.SAML, expiryTime: Date.now() + this.config.session.ttl, }; } @@ -101,7 +101,7 @@ export class SamlAuthentication extends AuthenticationType { // Can be improved to check if the token is expiring. async isValidCookie(cookie: SecuritySessionCookie): Promise { return ( - cookie.authType === this.type && + cookie.authType === AuthType.SAML && cookie.username && cookie.expiryTime && cookie.credentials?.authHeaderValue diff --git a/server/backend/opensearch_security_client.ts b/server/backend/opensearch_security_client.ts index fb7c7d11a..98befcfa4 100755 --- a/server/backend/opensearch_security_client.ts +++ b/server/backend/opensearch_security_client.ts @@ -40,7 +40,7 @@ export class SecurityClient { credentials, proxyCredentials: credentials, }; - } catch (error) { + } catch (error: any) { throw new Error(error.message); } } @@ -77,7 +77,7 @@ export class SecurityClient { selectedTenant: esResponse.user_requested_tenant, credentials, }; - } catch (error) { + } catch (error: any) { throw new Error(error.message); } } @@ -99,7 +99,7 @@ export class SecurityClient { tenants: esResponse.tenants, selectedTenant: esResponse.user_requested_tenant, }; - } catch (error) { + } catch (error: any) { throw new Error(error.message); } } @@ -111,7 +111,7 @@ export class SecurityClient { .callAsCurrentUser('opensearch_security.authinfo', { headers, }); - } catch (error) { + } catch (error: any) { throw new Error(error.message); } } @@ -122,7 +122,7 @@ export class SecurityClient { return await this.esClient .asScoped(request) .callAsCurrentUser('opensearch_security.multitenancyinfo'); - } catch (error) { + } catch (error: any) { throw new Error(error.message); } } @@ -130,7 +130,7 @@ export class SecurityClient { public async getTenantInfoWithInternalUser() { try { return this.esClient.callAsInternalUser('opensearch_security.tenantinfo'); - } catch (error) { + } catch (error: any) { throw new Error(error.message); } } @@ -140,7 +140,7 @@ export class SecurityClient { return await this.esClient .asScoped(request) .callAsCurrentUser('opensearch_security.tenantinfo'); - } catch (error) { + } catch (error: any) { throw new Error(error.message); } } @@ -149,7 +149,7 @@ export class SecurityClient { try { // response is expected to be an error await this.esClient.asScoped(request).callAsCurrentUser('opensearch_security.authinfo'); - } catch (error) { + } catch (error: any) { // the error looks like // wwwAuthenticateDirective: // ' @@ -175,7 +175,7 @@ export class SecurityClient { }; } throw Error('failed parsing SAML config'); - } catch (parsingError) { + } catch (parsingError: any) { console.log(parsingError); throw new Error(parsingError); } @@ -197,7 +197,7 @@ export class SecurityClient { return await this.esClient.asScoped().callAsCurrentUser('opensearch_security.authtoken', { body, }); - } catch (error) { + } catch (error: any) { console.log(error); throw new Error('failed to get token'); } diff --git a/server/index.ts b/server/index.ts index 99adb015b..c6cab52b6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,6 +17,27 @@ import { schema, TypeOf } from '@osd/config-schema'; import { PluginInitializerContext, PluginConfigDescriptor } from '../../../src/core/server'; import { SecurityPlugin } from './plugin'; +const validateAuthType = (value: string[]) => { + const supportedAuthTypes = [ + '', + 'basicauth', + '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 }), @@ -56,24 +77,46 @@ export const configSchema = schema.object({ keepalive: schema.boolean({ defaultValue: true }), }), auth: schema.object({ - type: schema.string({ - defaultValue: '', - validate(value) { - if ( - !['', 'basicauth', 'jwt', 'openid', 'saml', 'proxy', 'kerberos', 'proxycache'].includes( - value - ) - ) { - return `allowed auth.type are ['', 'basicauth', 'jwt', 'openid', 'saml', 'proxy', 'kerberos', 'proxycache']`; - } - }, - }), + 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) { + 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 }), @@ -84,15 +127,15 @@ export const configSchema = schema.object({ headers: schema.arrayOf(schema.string(), { defaultValue: [] }), show_for_parameter: schema.string({ defaultValue: '' }), valid_redirects: schema.arrayOf(schema.string(), { defaultValue: [] }), - button_text: schema.string({ defaultValue: 'Login with provider' }), + 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: 'Please login to OpenSearch Dashboards' }), + title: schema.string({ defaultValue: 'Log in to OpenSearch Dashboards' }), subtitle: schema.string({ defaultValue: - 'If you have forgotten your username or password, please ask your system administrator', + '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 @@ -177,16 +220,40 @@ export const configSchema = 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: 'Please login to OpenSearch Dashboards' }), + title: schema.string({ defaultValue: 'Log in to OpenSearch Dashboards' }), subtitle: schema.string({ defaultValue: - 'If you have forgotten your username or password, please ask your system administrator', + '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: '' }), }), }), + anonymous: schema.object({ + login: schema.object({ + buttonname: schema.string({ defaultValue: 'Log in as anonymous' }), + showbrandimage: schema.boolean({ defaultValue: false }), + 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 }), }), diff --git a/server/plugin.ts b/server/plugin.ts index b7c9e0ce8..84d94e4f1 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -114,7 +114,7 @@ export class SecurityPlugin implements Plugin