diff --git a/public/apps/login/login-page.tsx b/public/apps/login/login-page.tsx index 22421e3a7..449bd39de 100644 --- a/public/apps/login/login-page.tsx +++ b/public/apps/login/login-page.tsx @@ -219,10 +219,11 @@ export function LoginPage(props: LoginPageDeps) { renderLoginButton(AuthType.ANONYMOUS, ANONYMOUS_AUTH_LOGIN, anonymousConfig) ); } - - formBody.push(); - formBody.push(); - formBody.push(); + if (!authOpts.includes(AuthType.PROXY) || authOpts.length !== 2) { + formBody.push(); + formBody.push(); + formBody.push(); + } } break; } @@ -238,6 +239,12 @@ export function LoginPage(props: LoginPageDeps) { formBodyOp.push(renderLoginButton(AuthType.SAML, samlAuthLoginUrl, samlConfig)); break; } + case AuthType.PROXY: { + // formBody.pop(); + // formBody.pop(); + // formBody.pop(); + break; + } default: { setloginFailed(true); setloginError( diff --git a/server/auth/types/multiple/multi_auth.ts b/server/auth/types/multiple/multi_auth.ts index 8763eaa4f..8c536a47a 100644 --- a/server/auth/types/multiple/multi_auth.ts +++ b/server/auth/types/multiple/multi_auth.ts @@ -29,7 +29,7 @@ import { ANONYMOUS_AUTH_LOGIN, AuthType, LOGIN_PAGE_URI } from '../../../../comm import { composeNextUrlQueryParam } from '../../../utils/next_url'; import { MultiAuthRoutes } from './routes'; import { SecuritySessionCookie } from '../../../session/security_cookie'; -import { BasicAuthentication, OpenIdAuthentication, SamlAuthentication } from '../../types'; +import { BasicAuthentication, OpenIdAuthentication, ProxyAuthentication, SamlAuthentication } from '../../types'; export class MultipleAuthentication extends AuthenticationType { private authTypes: string | string[]; @@ -93,6 +93,19 @@ export class MultipleAuthentication extends AuthenticationType { this.authHandlers.set(AuthType.SAML, SamlAuth); break; } + case AuthType.PROXY: { + const ProxyAuth = new ProxyAuthentication( + this.config, + this.sessionStorageFactory, + this.router, + this.esClient, + this.coreSetup, + this.logger + ); + await ProxyAuth.init(); + this.authHandlers.set(AuthType.PROXY, ProxyAuth); + break; + } default: { throw new Error(`Unsupported authentication type: ${this.authTypes[i]}`); } @@ -115,7 +128,7 @@ export class MultipleAuthentication extends AuthenticationType { async getAdditionalAuthHeader( request: OpenSearchDashboardsRequest ): Promise { - // To Do: refactor this method to improve the effiency to get cookie, get cookie from input parameter + // To Do: refactor this method to improve the efficiency to get cookie, get cookie from input parameter const cookie = await this.sessionStorageFactory.asScoped(request).get(); const reqAuthType = cookie?.authType?.toLowerCase(); diff --git a/server/auth/types/proxy/proxy_auth.test.ts b/server/auth/types/proxy/proxy_auth.test.ts new file mode 100644 index 000000000..95e319056 --- /dev/null +++ b/server/auth/types/proxy/proxy_auth.test.ts @@ -0,0 +1,93 @@ +import { httpServerMock } from '../../../../../../src/core/server/http/http_server.mocks'; +import { IRouter, OpenSearchDashboardsRequest } from '../../../../../../src/core/server/http/router'; +import { SecurityPluginConfigType } from '../../../index'; +import { SecuritySessionCookie } from '../../../session/security_cookie'; +import { deflateValue } from '../../../utils/compression'; +import { + CoreSetup, + ILegacyClusterClient, + Logger, + SessionStorageFactory, +} from '../../../../../../src/core/server'; +import { ProxyAuthentication } from './proxy_auth'; + +describe('ProxyAuthentication', () => { + let proxyAuthentication: ProxyAuthentication; + let router: IRouter; + let core: CoreSetup; + let esClient: ILegacyClusterClient; + let sessionStorageFactory: SessionStorageFactory; + let logger: Logger; + + beforeEach(() => { + // Set up mock dependencies + router = {} as IRouter; + core = {} as CoreSetup; + esClient = {} as ILegacyClusterClient; + sessionStorageFactory = {} as SessionStorageFactory; + logger = {} as Logger; + + const config = ({ + proxy: { + extra_storage: { + cookie_prefix: 'testcookie', + additional_cookies: 5, + }, + }, + } as unknown) as SecurityPluginConfigType; + + proxyAuthentication = new ProxyAuthentication( + config, + sessionStorageFactory, + router, + esClient, + core, + logger + ); + }); + + it('should build auth header from cookie with authHeaderValue', () => { + + const cookie: SecuritySessionCookie = { + credentials: { + authHeaderValue: 'Bearer eyToken', + }, + }; + + const expectedHeaders = { + authorization: 'Bearer eyToken', + }; + + const headers = proxyAuthentication.buildAuthHeaderFromCookie(cookie); + + expect(headers).toEqual(expectedHeaders); + }); + + it('should get authHeaderValue from split cookies', () => { + const testString = 'Bearer eyCombinedToken'; + const testStringBuffer: Buffer = deflateValue(testString); + const cookieValue = testStringBuffer.toString('base64'); + const cookiePrefix = 'testcookie'; + const splitValueAt = Math.ceil(cookieValue.length / 5); + const mockRequest = httpServerMock.createRawRequest({ + state: { + [cookiePrefix + '1']: cookieValue.substring(0, splitValueAt), + [cookiePrefix + '2']: cookieValue.substring(splitValueAt), + }, + }); + OpenSearchDashboardsRequest.from(mockRequest); + const cookie: SecuritySessionCookie = { + credentials: { + authHeaderValueExtra: true, + }, + }; + + const expectedHeaders = { + authorization: testString, + }; + + const headers = proxyAuthentication.buildAuthHeaderFromCookie(cookie); + + expect(headers).toEqual(expectedHeaders); + }); +}); diff --git a/test/constant.ts b/test/constant.ts index 713ea05de..75e02fe43 100644 --- a/test/constant.ts +++ b/test/constant.ts @@ -26,3 +26,6 @@ export const ADMIN_PASSWORD: string = 'admin'; const ADMIN_USER_PASS: string = `${ADMIN_USER}:${ADMIN_PASSWORD}`; export const ADMIN_CREDENTIALS: string = `Basic ${Buffer.from(ADMIN_USER_PASS).toString('base64')}`; export const AUTHORIZATION_HEADER_NAME: string = 'Authorization'; +export const PROXY_USER: string = 'x-proxy-user'; +export const PROXY_ROLE: string = 'x-proxy-roles'; +export const PROXY_ADMIN_ROLE: string = 'admin'; diff --git a/test/jest_integration/proxy_auth.test.ts b/test/jest_integration/proxy_auth.test.ts new file mode 100644 index 000000000..8772bac99 --- /dev/null +++ b/test/jest_integration/proxy_auth.test.ts @@ -0,0 +1,101 @@ +/* + * 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 * as osdTestServer from '../../../../src/core/test_helpers/osd_server'; +import { Root } from '../../../../src/core/server/root'; +import { resolve } from 'path'; +import { describe, expect, it, beforeAll, afterAll } from '@jest/globals'; +import { + OPENSEARCH_DASHBOARDS_SERVER_USER, + OPENSEARCH_DASHBOARDS_SERVER_PASSWORD, + ADMIN_USER, + PROXY_USER, + PROXY_ROLE, + PROXY_ADMIN_ROLE, +} from '../constant'; + +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, + requestHeadersAllowlist: [ + 'securitytenant', + 'Authorization', + 'x-forwarded-for', + 'x-proxy-user', + 'x-proxy-roles', + ], + }, + opensearch_security: { + auth: { + type: 'proxy', + }, + proxycache: { + user_header: 'x-proxy-user', + roles_header: 'x-proxy-roles', + }, + }, + }, + { + // to make ignoreVersionMismatch setting work + // can be removed when we have corresponding ES version + dev: true, + } + ); + }); + + afterAll(async () => { + // shutdown OpenSearchDashboards server + await root.shutdown(); + }); + + it('can access home page with proxy header', async () => { + const response = await osdTestServer.request + .get(root, 'app/home#/') + .set(PROXY_USER, ADMIN_USER) + .set(PROXY_ROLE, PROXY_ADMIN_ROLE); + expect(response.status).toEqual(200); + }); + + it('cannot access home page without proxy header', async () => { + const response = await osdTestServer.request.get(root, 'app/home#/'); + expect(response.status).toEqual(401); + }); + + it('cannot access home page with partial proxy header', async () => { + const response = await osdTestServer.request + .get(root, 'app/home#/') + .set(PROXY_USER, ADMIN_USER); + expect(response.status).toEqual(401); + }); + + it('cannot access home page with partial proxy header2', async () => { + const response = await osdTestServer.request + .get(root, 'app/home#/') + .set(PROXY_ROLE, PROXY_ADMIN_ROLE); + expect(response.status).toEqual(401); + }); +}); diff --git a/test/jest_integration/proxy_multiauth.test.ts b/test/jest_integration/proxy_multiauth.test.ts new file mode 100644 index 000000000..97e9f8bb5 --- /dev/null +++ b/test/jest_integration/proxy_multiauth.test.ts @@ -0,0 +1,271 @@ +/* + * 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 * as osdTestServer from '../../../../src/core/test_helpers/osd_server'; +import { Root } from '../../../../src/core/server/root'; +import { resolve } from 'path'; +import { describe, expect, it, beforeAll, afterAll } from '@jest/globals'; +import { + ADMIN_CREDENTIALS, + OPENSEARCH_DASHBOARDS_SERVER_USER, + OPENSEARCH_DASHBOARDS_SERVER_PASSWORD, ADMIN_USER, PROXY_ADMIN_ROLE, +} from '../constant'; +import wreck from '@hapi/wreck'; +import { Builder, By, until } from 'selenium-webdriver'; +import { Options } from 'selenium-webdriver/firefox'; + +describe('start OpenSearch Dashboards server', () => { + let root: Root; + let config; + + // XPath Constants + const signInBtnXPath = '//*[@id="btn-sign-in"]'; + // Browser Settings + const browser = 'firefox'; + const options = new Options().headless(); + + beforeAll(async () => { + root = osdTestServer.createRootWithSettings( + { + plugins: { + scanDirs: [resolve(__dirname, '../..')], + }, + home: { disableWelcomeScreen: true }, + server: { + host: 'localhost', + port: 5601, + }, + logging: { + silent: true, + verbose: false, + }, + opensearch: { + hosts: ['https://localhost:9200'], + ignoreVersionMismatch: true, + ssl: { verificationMode: 'none' }, + username: OPENSEARCH_DASHBOARDS_SERVER_USER, + password: OPENSEARCH_DASHBOARDS_SERVER_PASSWORD, + requestHeadersAllowlist: [ + 'securitytenant', + 'Authorization', + 'x-forwarded-for', + 'x-proxy-user', + 'x-proxy-roles', + ], + }, + opensearch_security: { + auth: { + anonymous_auth_enabled: false, + type: ['basicauth', 'proxy'], + multiple_auth_enabled: true, + }, + proxycache: { + user_header: 'x-proxy-user', + roles_header: 'x-proxy-roles', + }, + multitenancy: { + enabled: true, + tenants: { + enable_global: true, + enable_private: true, + 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('Starting to Download Flights Sample Data'); + await wreck.post('http://localhost:5601/api/sample_data/flights', { + payload: {}, + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + authorization: ADMIN_CREDENTIALS, + security_tenant: 'global', + }, + }); + console.log('Downloaded Sample Data'); + const getConfigResponse = await wreck.get( + 'https://localhost:9200/_plugins/_security/api/securityconfig', + { + rejectUnauthorized: false, + headers: { + authorization: ADMIN_CREDENTIALS, + }, + } + ); + const responseBody = (getConfigResponse.payload as Buffer).toString(); + config = JSON.parse(responseBody).config; + const proxyConfig = { + http_enabled: true, + transport_enabled: true, + order: 0, + http_authenticator: { + challenge: false, + type: 'proxy', + config: { + user_header: 'x-proxy-user', + roles_header: 'x-proxy-roles', + }, + }, + authentication_backend: { + type: 'noop', + config: {}, + }, + }; + try { + config.dynamic!.authc!.proxy_auth_domain = proxyConfig; + config.dynamic!.authc!.basic_internal_auth_domain.http_authenticator.challenge = false; + config.dynamic!.http!.anonymous_auth_enabled = false; + await wreck.put('https://localhost:9200/_plugins/_security/api/securityconfig/config', { + payload: config, + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + authorization: ADMIN_CREDENTIALS, + }, + }); + } catch (error) { + console.log('Got an error while updating security config!!', error.stack); + fail(error); + } + }); + + afterAll(async () => { + console.log('Remove the Sample Data'); + await wreck + .delete('http://localhost:5601/api/sample_data/flights', { + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + authorization: ADMIN_CREDENTIALS, + }, + }) + .then((value) => { + Promise.resolve(value); + }) + .catch((value) => { + Promise.resolve(value); + }); + console.log('Remove the Security Config'); + await wreck + .patch('https://localhost:9200/_plugins/_security/api/securityconfig', { + payload: [ + { + op: 'remove', + path: '/config/dynamic/authc/proxy_auth_domain', + }, + ], + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + authorization: ADMIN_CREDENTIALS, + }, + }) + .then((value) => { + Promise.resolve(value); + }) + .catch((value) => { + Promise.resolve(value); + }); + // shutdown OpenSearchDashboards server + await root.shutdown(); + }); + + it('Verify Proxy access to dashboards', async () => { + console.log('Wreck access home page'); + await wreck + .get('http://localhost:5601/app/home#', { + rejectUnauthorized: true, + headers: { + 'Content-Type': 'application/json', + PROXY_USER: ADMIN_USER, + PROXY_ROLE: PROXY_ADMIN_ROLE, + }, + }) + .then((value) => { + Promise.resolve(value); + }) + .catch((value) => { + Promise.resolve(value); + }); + }); + it('Login to Dashboards and resume from nextUrl', async () => { + const urlWithHash = `http://localhost:5601/app/security-dashboards-plugin#/getstarted`; + const loginUrlWithNextUrl = `http://localhost:5601/app/login?nextUrl=%2Fapp%2Fsecurity-dashboards-plugin#/getstarted`; + const driver = getDriver(browser, options).build(); + await driver.manage().deleteAllCookies(); + await driver.get(loginUrlWithNextUrl); + await driver.wait(until.elementsLocated(By.xpath(signInBtnXPath)), 20000); + await driver.findElement(By.xpath(signInBtnXPath)).click(); + // TODO Use a better XPath. + await driver.wait( + until.elementsLocated(By.xpath('/html/body/div[1]/div/header/div/div[2]')), + 20000 + ); + const windowHash = await driver.getCurrentUrl(); + console.log('windowHash: ' + windowHash); + expect(windowHash).toEqual(urlWithHash); + const cookie = await driver.manage().getCookies(); + expect(cookie.length).toEqual(3); + await driver.manage().deleteAllCookies(); + await driver.quit(); + }); + + it('Login to Dashboards without nextUrl', async () => { + const urlWithoutHash = `http://localhost:5601/app/home#/`; + const loginUrl = `http://localhost:5601/app/login`; + const driver = getDriver(browser, options).build(); + await driver.manage().deleteAllCookies(); + await driver.get(loginUrl); + await driver.wait(until.elementsLocated(By.xpath(signInBtnXPath)), 20000); + await driver.findElement(By.xpath(signInBtnXPath)).click(); + // TODO Use a better XPath. + await driver.wait( + until.elementsLocated(By.xpath('/html/body/div[1]/div/header/div/div[2]')), + 20000 + ); + await driver.wait(until.elementsLocated(By.css('img[data-test-subj="defaultLogo"]')), 20000); + await driver.wait( + until.elementsLocated(By.css('section[aria-labelledby="homDataAdd__title"]')), + 20000 + ); + await driver.wait( + until.elementsLocated(By.css('section[aria-labelledby="homDataManage__title"]')), + 20000 + ); + const windowHash = await driver.getCurrentUrl(); + console.log('windowHash: ' + windowHash); + expect(windowHash).toEqual(urlWithoutHash); + const cookie = await driver.manage().getCookies(); + expect(cookie.length).toEqual(3); + await driver.manage().deleteAllCookies(); + await driver.quit(); + }); +}); + +function getDriver(browser: string, options: Options) { + return new Builder().forBrowser(browser).setFirefoxOptions(options); +}