From 03901fc840d3c7f8beff193f3270265e64beeedf Mon Sep 17 00:00:00 2001 From: Nic Townsend Date: Tue, 15 Dec 2020 12:21:11 +0000 Subject: [PATCH 1/4] fix: render index for unknown requests - always return index if a path cannot be met - security tests to ensure index is protected Contributes to: strimzi/strimzi-ui#154 Signed-off-by: Nic Townsend --- server/client/client.feature | 106 +++++++++++++++--------- server/client/router.ts | 13 ++- server/test_common/commonServerSteps.ts | 18 +++- 3 files changed, 88 insertions(+), 49 deletions(-) diff --git a/server/client/client.feature b/server/client/client.feature index c50fc51f..7ae11b19 100644 --- a/server/client/client.feature +++ b/server/client/client.feature @@ -4,45 +4,75 @@ Feature: client module Behaviours and capabilities provided by the client module - Scenario Outline: If no asset can be served, the client module returns 404 - Given a 'client_only' server configuration - And There are no files to serve - And Authentication is required - And I run an instance of the Strimzi-UI server - When I make a 'get' request to '' - Then I get the expected status code '' response + Scenario Outline: With auth '' - If no asset can be served, the client module returns 404 + Given a 'client_only' server configuration + And There are no files to serve + And '' authentication is required + And I run an instance of the Strimzi-UI server + When I make a 'get' request to '' + Then I get the expected status code '' response - Examples: - | Asset | StatusCode | - | /index.html | 404 | - | /images/picture.svg | 404 | - | /doesnotexist.html | 404 | - | /someroute | 404 | - | /protected.html | 404 | - | / | 404 | + Examples: + | Asset | Auth | StatusCode | + | /index.html | scram | 404 | + | /images/picture.svg | scram | 404 | + | /doesnotexist.html | scram | 404 | + | /someroute | scram | 404 | + | /protected.html | scram | 404 | + | / | scram | 404 | + | /index.html | oauth | 404 | + | /images/picture.svg | oauth | 404 | + | /doesnotexist.html | oauth | 404 | + | /someroute | oauth | 404 | + | /protected.html | oauth | 404 | + | / | oauth | 404 | + | /index.html | none | 404 | + | /images/picture.svg | none | 404 | + | /doesnotexist.html | none | 404 | + | /someroute | none | 404 | + | /protected.html | none | 404 | + | / | none | 404 | - Scenario Outline: If assets can be served, the client module returns the appropriate return code for a request of - Given a 'client_only' server configuration - And There are files to serve - And Authentication is required - And I run an instance of the Strimzi-UI server - When I make a 'get' request to '' - Then I get the expected status code '' response - # if the route (not file) is not matched, we redirect to index.html. Hence / and someroute response - Examples: - | Asset | StatusCode | - | /index.html | 200 | - | /images/picture.svg | 200 | - | /doesnotexist.html | 404 | - | /someroute | 302 | - | /protected.html | 511 | - | / | 200 | + Scenario Outline: With auth '' - if assets can be served, the client module returns the appropriate return code for a request of + Given a 'client_only' server configuration + And There are files to serve + And '' authentication is required + And I run an instance of the Strimzi-UI server + When I make a 'get' request to '' + Then I get the expected status code '' response + # if the route (not file) is not matched, we render index.html as the repsonse (200) + Examples: + | Asset | Auth | StatusCode | + | /index.html | scram | 511 | + | /images/picture.svg | scram | 200 | + | /doesnotexist.html | scram | 511 | + | /someroute | scram | 511 | + | /protected.html | scram | 511 | + | / | scram | 511 | + | /index.html | oauth | 511 | + | /images/picture.svg | oauth | 200 | + | /doesnotexist.html | oauth | 511 | + | /someroute | oauth | 511 | + | /protected.html | oauth | 511 | + | / | oauth | 511 | + | /index.html | none | 200 | + | /images/picture.svg | none | 200 | + | /doesnotexist.html | none | 200 | + | /someroute | none | 200 | + | /protected.html | none | 200 | + | / | none | 200 | - Scenario: Critical configuration is templated into index.html so the client can bootstrap - Given a 'client_only' server configuration - And There are files to serve - And Authentication is required - And I run an instance of the Strimzi-UI server - When I make a 'get' request to '/index.html' - Then the file is returned as with the expected configuration included + Scenario Outline: With auth '' - Critical configuration is templated into index.html so the client can bootstrap + Given a 'client_only' server configuration + And There are files to serve + And '' authentication is required + And I am authenticated + And I run an instance of the Strimzi-UI server + When I make a 'get' request to '/index.html' + Then the file is returned as with the expected configuration included + Examples: + | Auth | + | scram | + | oauth | + | none | diff --git a/server/client/router.ts b/server/client/router.ts index 09f3df4a..f0f07691 100644 --- a/server/client/router.ts +++ b/server/client/router.ts @@ -32,22 +32,19 @@ export const ClientModule: UIServerModule = { // add the auth middleware to all non public files protectedFiles.forEach((file) => routerForModule.get(`${file}`, authFn)); - // return index.html, with configuration templated in + // Direct request for index, serve it (behind auth check) hasIndexFile && - routerForModule.get('/index.html', renderTemplate(indexFile)); + routerForModule.get('/index.html', authFn, renderTemplate(indexFile)); // host all files from the client dir routerForModule.get( '*', - expressStaticGzip(builtClientDir, {}), + expressStaticGzip(builtClientDir, { index: false }), express.static(builtClientDir, { index: false }) ); - // if no match, not a file (path contains '.'), and we have an index.html file, redirect to it (ie return index so client navigation logic kicks in). Else do nothing (404 unless another module handles it) - hasIndexFile && - routerForModule.get(/^((?!\.).)+$/, (req, res) => - res.redirect(`/index.html`) - ); + // If no match and we have an index, serve it (behind auth check) + hasIndexFile && routerForModule.get('*', authFn, renderTemplate(indexFile)); return exit({ mountPoint: '/', routerForModule }); }, diff --git a/server/test_common/commonServerSteps.ts b/server/test_common/commonServerSteps.ts index 21f5fa12..f9a515c1 100644 --- a/server/test_common/commonServerSteps.ts +++ b/server/test_common/commonServerSteps.ts @@ -4,6 +4,7 @@ */ import request from 'supertest'; import { returnExpress } from 'core'; +import { mocked } from 'ts-jest/utils'; import { Given, When, And, Then } from 'jest-cucumber-fusion'; import { serverConfigType, @@ -21,6 +22,8 @@ import { requests } from './testGQLRequests'; import MockedWebSocket from 'ws'; jest.mock('ws'); +jest.mock('../placeholderFunctionsToReplace'); +import { authFunction } from '../placeholderFunctionsToReplace'; type supertestRequestType = request.SuperTest; @@ -47,6 +50,8 @@ const { resetWorld, stepWhichUpdatesWorld, stepWithWorld } = worldGenerator( beforeEach(() => { resetWorld(); + jest.resetAllMocks(); + mocked(authFunction).mockReturnValue((_req, _res, next) => next()); }); Given( @@ -60,13 +65,16 @@ Given( ); And( - 'Authentication is required', - stepWhichUpdatesWorld((world) => { + /^'(\S+)' authentication is required$/, + stepWhichUpdatesWorld((world, auth) => { + if ((auth as string) !== 'none') { + mocked(authFunction).mockReturnValue((_req, res) => res.sendStatus(511)); + } const { configuration } = world; return { ...world, configuration: merge(configuration, { - authentication: { strategy: 'oauth' }, + authentication: { strategy: auth }, }), }; }) @@ -84,6 +92,10 @@ And( }) ); +And('I am authenticated', () => { + mocked(authFunction).mockReturnValue((_req, _res, next) => next()); +}); + And( 'I enable WebSocket connections on the Strimzi-UI server', stepWhichUpdatesWorld((world) => { From f03fb17d671db7dd12e06e5e8cef9d7e74ec46cb Mon Sep 17 00:00:00 2001 From: Nic Townsend Date: Mon, 14 Dec 2020 15:06:32 +0000 Subject: [PATCH 2/4] fix: stop test assets bundled in production - restructure ConfigFeatureFlag assets so they are no longer exporting test code as runtime dependencies Contributes to: strimzi/strimzi-ui#152 Signed-off-by: Nic Townsend --- .../ConfigFeatureFlag.assets.ts | 62 ----------------- .../ConfigFeatureFlag.spec.tsx | 3 +- .../ConfigFeatureFlag.testassets.ts | 66 +++++++++++++++++++ 3 files changed, 67 insertions(+), 64 deletions(-) create mode 100644 client/Contexts/ConfigFeatureFlag/ConfigFeatureFlag.testassets.ts diff --git a/client/Contexts/ConfigFeatureFlag/ConfigFeatureFlag.assets.ts b/client/Contexts/ConfigFeatureFlag/ConfigFeatureFlag.assets.ts index f7dbecc2..a38fe284 100644 --- a/client/Contexts/ConfigFeatureFlag/ConfigFeatureFlag.assets.ts +++ b/client/Contexts/ConfigFeatureFlag/ConfigFeatureFlag.assets.ts @@ -2,16 +2,10 @@ * Copyright Strimzi authors. * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). */ -// deliberately not using the barrel as causes a circular dependency -import { - generateMockDataResponseForGQLRequest, - generateMockErrorResponseForGQLRequest, -} from 'utils/test/withApollo/withApollo.util'; import { ConfigFeatureFlagType, ApolloQueryResponseType, } from './ConfigFeatureFlag.types'; -import { GET_CONFIG } from 'Queries/Config'; export const defaultClientConfig: ApolloQueryResponseType = { client: {}, @@ -33,59 +27,3 @@ export const defaultConfigFeatureFlagValue: ConfigFeatureFlagType = { // NO-OP - placeholder }, }; - -const mockClientResponse = { - about: { - version: '1.2.3', - }, -}; -const mockFeatureFlagResponse = { - client: { - Home: { - showVersion: true, - }, - Pages: { - PlaceholderHome: true, - }, - }, -}; - -const mockError = new Error('Example error case'); - -export const mockData = { - mockClientResponse, - mockFeatureFlagResponse, - mockError, -}; - -const additionalRequestArgs = { - variables: {}, - context: { - purpose: 'config', - }, -}; - -const noOpRequest = generateMockDataResponseForGQLRequest( - GET_CONFIG, - {}, - additionalRequestArgs -); -const successRequest = generateMockDataResponseForGQLRequest( - GET_CONFIG, - { - client: mockClientResponse, - featureFlags: mockFeatureFlagResponse, - }, - additionalRequestArgs -); -const errorRequest = generateMockErrorResponseForGQLRequest( - GET_CONFIG, - mockError, - additionalRequestArgs -); - -export const mockRequests = { - noOpRequest, - successRequest, - errorRequest, -}; diff --git a/client/Contexts/ConfigFeatureFlag/ConfigFeatureFlag.spec.tsx b/client/Contexts/ConfigFeatureFlag/ConfigFeatureFlag.spec.tsx index 4c81eb82..9bd189f6 100644 --- a/client/Contexts/ConfigFeatureFlag/ConfigFeatureFlag.spec.tsx +++ b/client/Contexts/ConfigFeatureFlag/ConfigFeatureFlag.spec.tsx @@ -21,11 +21,10 @@ import { ConfigFeatureFlagProvider, ConfigFeatureFlagConsumer, } from './Context'; +import { mockData, mockRequests } from './ConfigFeatureFlag.testassets'; import { defaultClientConfig, defaultConfigFeatureFlagValue, - mockData, - mockRequests, } from './ConfigFeatureFlag.assets'; import { ConfigFeatureFlagType } from './ConfigFeatureFlag.types'; diff --git a/client/Contexts/ConfigFeatureFlag/ConfigFeatureFlag.testassets.ts b/client/Contexts/ConfigFeatureFlag/ConfigFeatureFlag.testassets.ts new file mode 100644 index 00000000..8a518946 --- /dev/null +++ b/client/Contexts/ConfigFeatureFlag/ConfigFeatureFlag.testassets.ts @@ -0,0 +1,66 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +// deliberately not using the barrel as causes a circular dependency +import { + generateMockDataResponseForGQLRequest, + generateMockErrorResponseForGQLRequest, +} from 'utils/test/withApollo/withApollo.util'; +import { GET_CONFIG } from 'Queries/Config'; + +const mockClientResponse = { + about: { + version: '1.2.3', + }, +}; +const mockFeatureFlagResponse = { + client: { + Home: { + showVersion: true, + }, + Pages: { + PlaceholderHome: true, + }, + }, +}; + +const mockError = new Error('Example error case'); + +export const mockData = { + mockClientResponse, + mockFeatureFlagResponse, + mockError, +}; + +const additionalRequestArgs = { + variables: {}, + context: { + purpose: 'config', + }, +}; + +const noOpRequest = generateMockDataResponseForGQLRequest( + GET_CONFIG, + {}, + additionalRequestArgs +); +const successRequest = generateMockDataResponseForGQLRequest( + GET_CONFIG, + { + client: mockClientResponse, + featureFlags: mockFeatureFlagResponse, + }, + additionalRequestArgs +); +const errorRequest = generateMockErrorResponseForGQLRequest( + GET_CONFIG, + mockError, + additionalRequestArgs +); + +export const mockRequests = { + noOpRequest, + successRequest, + errorRequest, +}; From 82767da5cbaef1498ec8541051b725ae72292391 Mon Sep 17 00:00:00 2001 From: Nic Townsend Date: Thu, 10 Dec 2020 12:25:17 +0000 Subject: [PATCH 3/4] feat: local authentication - introduce passport - add a passport local strategy for authentication - introduce Authentication interface to contain the multiple auth checks (authenticate, checkAuth, logout) - scram router module for authentication, logout, and auth check - no op for no auth - extend auth support to provide additional functions to all modules for checking auth, logging out - remove empty placeholder file Contributes to: strimzi/strimzi-ui#106 Signed-off-by: Nic Townsend --- config/static.ts | 6 +- linting/eslint.config.js | 2 +- package-lock.json | 122 +++++++++++++++ package.json | 12 +- server/README.md | 38 ++--- server/api/api.feature | 34 ++--- server/api/api.steps.ts | 7 +- server/api/router.ts | 6 +- server/client/client.feature | 8 + server/client/client.steps.ts | 22 +-- server/client/controller.ts | 6 +- server/client/router.ts | 10 +- server/config/config.feature | 16 +- server/config/router.ts | 4 +- server/core/README.md | 4 +- server/core/app.ts | 12 +- server/core/core.feature | 10 +- server/core/modules.ts | 3 +- server/log/log.feature | 32 ++-- server/log/router.ts | 4 +- server/mockapi/data.ts | 16 +- server/mockapi/mockapi.feature | 16 +- server/placeholderFunctionsToReplace.ts | 29 ---- server/security/bootstrap.feature | 34 +++++ server/security/bootstrap.steps.ts | 135 +++++++++++++++++ server/security/bootstrap.ts | 58 ++++++++ server/security/index.ts | 7 + server/security/routeConfig.ts | 10 ++ server/security/router.ts | 41 ++++++ server/security/security.feature | 62 ++++++++ server/security/security.steps.ts | 7 + .../strategy/scram/scramAuthenticator.feature | 28 ++++ .../scram/scramAuthenticator.steps.ts | 139 ++++++++++++++++++ .../strategy/scram/scramAuthenticator.ts | 43 ++++++ .../security/strategy/strategyFactory.feature | 13 ++ .../strategy/strategyFactory.steps.ts | 44 ++++++ server/security/strategy/strategyFactory.ts | 39 +++++ server/security/types.ts | 16 ++ server/test_common/commonServerSteps.ts | 138 ++++++++++++++--- server/test_common/testConfigs.ts | 79 +++++----- server/tsconfig.json | 2 +- server/types.ts | 30 ++-- .../jest_cucumber_support/commonTestTypes.ts | 3 + test_common/jest_cucumber_support/index.ts | 2 +- utils/dev_config/mockadmin.config.js | 6 +- utils/dev_config/server.dev.config.js | 4 + utils/dev_config/server.e2e.config.js | 3 + utils/tooling/runtimeDevUtils.js | 3 + 48 files changed, 1129 insertions(+), 236 deletions(-) delete mode 100644 server/placeholderFunctionsToReplace.ts create mode 100644 server/security/bootstrap.feature create mode 100644 server/security/bootstrap.steps.ts create mode 100644 server/security/bootstrap.ts create mode 100644 server/security/index.ts create mode 100644 server/security/routeConfig.ts create mode 100644 server/security/router.ts create mode 100644 server/security/security.feature create mode 100644 server/security/security.steps.ts create mode 100644 server/security/strategy/scram/scramAuthenticator.feature create mode 100644 server/security/strategy/scram/scramAuthenticator.steps.ts create mode 100644 server/security/strategy/scram/scramAuthenticator.ts create mode 100644 server/security/strategy/strategyFactory.feature create mode 100644 server/security/strategy/strategyFactory.steps.ts create mode 100644 server/security/strategy/strategyFactory.ts create mode 100644 server/security/types.ts diff --git a/config/static.ts b/config/static.ts index 49c30b1f..0be85b2e 100644 --- a/config/static.ts +++ b/config/static.ts @@ -19,9 +19,6 @@ const client: Config = { const server: Config = { defaultConfig: { configValue: { - authentication: { - strategy: 'none', - }, client: { configOverrides: {}, transport: {}, @@ -40,6 +37,9 @@ const server: Config = { contextRoot: '/', port: 9080, transport: {}, + authentication: { + type: 'none', + }, }, session: { name: 'strimzi-ui', diff --git a/linting/eslint.config.js b/linting/eslint.config.js index 450dde5c..4656d9ef 100644 --- a/linting/eslint.config.js +++ b/linting/eslint.config.js @@ -12,7 +12,7 @@ const rulesets = [ const customRules = { // prefer spaces (2) over tabs for indentation - tab width can be changed, space cannot - indent: ['error', 2], + indent: ['error', 2, { SwitchCase: 1 }], // all lines to have semicolons to end statements semi: ['error', 'always'], }; diff --git a/package-lock.json b/package-lock.json index 77589ef6..7a417634 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6295,6 +6295,36 @@ "integrity": "sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==", "dev": true }, + "@types/passport": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.4.tgz", + "integrity": "sha512-h5OfAbfBBYSzjeU0GTuuqYEk9McTgWeGQql9g3gUw2/NNCfD7VgExVRYJVVeU13Twn202Mvk9BT0bUrl30sEgA==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, + "@types/passport-local": { + "version": "1.0.33", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.33.tgz", + "integrity": "sha512-+rn6ZIxje0jZ2+DAiWFI8vGG7ZFKB0hXx2cUdMmudSWsigSq6ES7Emso46r4HJk0qCgrZVfI8sJiM7HIYf4SbA==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "@types/passport-strategy": { + "version": "0.2.35", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", + "integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*" + } + }, "@types/pino": { "version": "6.3.3", "resolved": "https://registry.npmjs.org/@types/pino/-/pino-6.3.3.tgz", @@ -7677,6 +7707,15 @@ "picomatch": "^2.0.4" } }, + "apollo-cache": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/apollo-cache/-/apollo-cache-1.3.5.tgz", + "integrity": "sha512-1XoDy8kJnyWY/i/+gLTEbYLnoiVtS8y7ikBr/IfmML4Qb+CM7dEEbIUOjnY716WqmZ/UpXIxTfJsY7rMcqiCXA==", + "requires": { + "apollo-utilities": "^1.3.4", + "tslib": "^1.10.0" + } + }, "apollo-cache-control": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.11.4.tgz", @@ -7686,6 +7725,21 @@ "apollo-server-plugin-base": "^0.10.2" } }, + "apollo-client": { + "version": "2.6.10", + "resolved": "https://registry.npmjs.org/apollo-client/-/apollo-client-2.6.10.tgz", + "integrity": "sha512-jiPlMTN6/5CjZpJOkGeUV0mb4zxx33uXWdj/xQCfAMkuNAC3HN7CvYDyMHHEzmcQ5GV12LszWoQ/VlxET24CtA==", + "requires": { + "@types/zen-observable": "^0.8.0", + "apollo-cache": "1.3.5", + "apollo-link": "^1.0.0", + "apollo-utilities": "1.3.4", + "symbol-observable": "^1.0.2", + "ts-invariant": "^0.4.0", + "tslib": "^1.10.0", + "zen-observable": "^0.8.0" + } + }, "apollo-datasource": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/apollo-datasource/-/apollo-datasource-0.7.2.tgz", @@ -8405,6 +8459,14 @@ "integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==", "dev": true }, + "axios": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz", + "integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, "babel-code-frame": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", @@ -15354,6 +15416,21 @@ } } }, + "graphql-ws": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-1.14.0.tgz", + "integrity": "sha512-cZ7ly3m6Wj1PiP8dydEqy9X5QO8UxeqvHDAAEGBRWZWHVGPmIoDWAT9Lj3uFXP+H9bTfmt8K6qIu0fsUDb3kHQ==", + "requires": { + "ws": "^7.4.0" + }, + "dependencies": { + "ws": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.0.tgz", + "integrity": "sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ==" + } + } + }, "growly": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", @@ -24418,6 +24495,18 @@ "tslib": "^1.10.0" } }, + "nock": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.0.5.tgz", + "integrity": "sha512-1ILZl0zfFm2G4TIeJFW0iHknxr2NyA+aGCMTjDVUsBY4CkMRispF1pfIYkTRdAR/3Bg+UzdEuK0B6HczMQZcCg==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "lodash.set": "^4.3.2", + "propagate": "^2.0.0" + } + }, "node-dir": { "version": "0.1.17", "resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz", @@ -25324,6 +25413,28 @@ "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=" }, + "passport": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.4.1.tgz", + "integrity": "sha512-IxXgZZs8d7uFSt3eqNjM9NQ3g3uQCW5avD8mRNoXV99Yig50vjuaez6dQK2qC0kVWPRTujxY0dWgGfT09adjYg==", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1" + } + }, + "passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4=", + "requires": { + "passport-strategy": "1.x.x" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" + }, "path-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", @@ -25387,6 +25498,11 @@ "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", "dev": true }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" + }, "pbkdf2": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.1.tgz", @@ -26542,6 +26658,12 @@ "react-is": "^16.8.1" } }, + "propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true + }, "property-information": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", diff --git a/package.json b/package.json index 50a84b92..757c30ac 100644 --- a/package.json +++ b/package.json @@ -45,17 +45,22 @@ "dependencies": { "@apollo/client": "^3.2.5", "@apollo/react-hooks": "^4.0.0", - "@walmartlabs/json-to-simple-graphql-schema": "^2.0.3", "@types/express-session": "^1.17.2", + "@walmartlabs/json-to-simple-graphql-schema": "^2.0.3", + "apollo-client": "^2.6.10", "apollo-link-http": "^1.5.17", "apollo-server-express": "^2.18.2", "compression-webpack-plugin": "^6.0.5", "css-minimizer-webpack-plugin": "^1.1.5", + "axios": "^0.21.0", + "body-parser": "^1.19.0", "express": "^4.17.1", "express-session": "^1.17.1", "express-static-gzip": "^2.1.0", "fromentries": "^1.3.2", "graphql": "^15.4.0", + "graphql-tag": "^2.11.0", + "graphql-ws": "^1.14.0", "helmet": "^4.2.0", "html-webpack-plugin": "^5.0.0-alpha.14", "http-proxy": "^1.18.1", @@ -68,6 +73,8 @@ "lodash.set": "^4.3.2", "mini-css-extract-plugin": "^1.2.1", "mustache": "^4.0.1", + "passport": "^0.4.1", + "passport-local": "^1.0.0", "pino": "^6.7.0", "pino-filter": "^1.0.0", "pino-http": "^5.3.0", @@ -99,6 +106,8 @@ "@types/jest": "^26.0.15", "@types/mustache": "^4.0.1", "@types/node": "^14.14.6", + "@types/passport": "^1.0.4", + "@types/passport-local": "^1.0.33", "@types/pino": "^6.3.3", "@types/pino-http": "^5.0.5", "@types/react-dom": "^16.9.9", @@ -127,6 +136,7 @@ "license-check-and-add": "^3.0.4", "lint-staged": "^10.5.1", "mock-socket": "^9.0.3", + "nock": "^13.0.5", "nodemon": "^2.0.6", "npm-run-all": "^4.1.5", "prettier": "^2.1.2", diff --git a/server/README.md b/server/README.md index 24837192..7ecc266b 100644 --- a/server/README.md +++ b/server/README.md @@ -20,22 +20,22 @@ This directory contains all server code for the Strimzi UI - ie code which is re As described in [the configuration approach](../docs/Architecture.md#configuration-and-feature-flagging), the UI server's configuration is provided via a file, which is then watched at runtime for modification. This configuration file is expected to be called `server.config.json` (available in the same directory as the `node` executable is run from), but this can be configured at runtime via environment variable `configPath`, dictating a different path and file name. The file must be either valid JSON or JS. The server also hosts configuration for discovery by the client via the `config` module. The configuration options for the server provided in the previously mentioned configuration file are as follows: -| Configuration | Required | Default | Purpose | -| ---------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| authentication.strategy | No | `none` | What authentication strategy to use to authenticate users. See [the security section](#security) for details of the available options. | -| authentication.configuration | No | `{}` | Any additional configuration required for the provided authentication strategy `authentication.strategy` . See [the security section](#security) for details of the available options. | -| client.configOverrides | No | `{}` | Overrides to send to the client. See [client configuration for further details](#client-configuration). These values will take precedence over any others provided. | -| client.publicDir | No | `/dist/client` | The location of the built client to serve. | -| client.transport.cert | No | N/A - if one of `client.transport.cert` or `client.transport.key` are not provided, server will be HTTP | PEM certificate presented to browsers on connecting to the UI server. | -| client.transport.key | No | N/A - if one of `client.transport.cert` or `client.transport.key` are not provided, server will be HTTP | PEM certificate private key for the certificate provided in `client.transport.cert`. | -| client.transport.ciphers | No | default set from [node's tls module](https://nodejs.org/api/tls.html#tls_modifying_the_default_tls_cipher_suite) | TLS ciphers used/supported by the HTTPS server for client negotiation. Only applies if starting an HTTPS server. | -| client.transport.minTLS | No | `TLSv1.2` | Minimum TLS version supported by the server. Only applies if starting an HTTPS server. Set to `TLSv1.2` for browser compatibility. | -| featureFlags | No | `{}` | Feature flag overrides to set. The configuration is as per the format specified [here](#feature-flags). These values will take precedence over any others provided. | -| hostname | No | '0.0.0.0' | The hostname the UI server will be bound to. | -| logging | No | TBD | Logging configuration settings. Format to be defined in https://github.com/strimzi/strimzi-ui/issues/24 | -| modules | No | Object - [enabled modules and configuration can be found here](../docs/Architecture.md#router-controller-data-pattern) | The modules which are either enabled or disabled. | -| port | No | 3000 | The port the UI server will be bound to. | -| proxy.transport.cert | No | If not provided, SSL certificate validation of the upstream admin server is disabled | CA certificate in PEM format of the backend admin server api requests are to be sent to. | -| proxy.hostname | Yes | N/A | The hostname of the admin server to send api requests to. | -| proxy.port | Yes | N/A | The port of the admin server to send api requests to. | -| session.name | no | `strimzi-ui` | The name used to identify the session cookie | +| Configuration | Required | Default | Purpose | +| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| client.configOverrides | No | `{}` | Overrides to send to the client. See [client configuration for further details](#client-configuration). These values will take precedence over any others provided. | +| client.publicDir | No | `/dist/client` | The location of the built client to serve. | +| client.transport.cert | No | N/A - if one of `client.transport.cert` or `client.transport.key` are not provided, server will be HTTP | PEM certificate presented to browsers on connecting to the UI server. | +| client.transport.key | No | N/A - if one of `client.transport.cert` or `client.transport.key` are not provided, server will be HTTP | PEM certificate private key for the certificate provided in `client.transport.cert`. | +| client.transport.ciphers | No | default set from [node's tls module](https://nodejs.org/api/tls.html#tls_modifying_the_default_tls_cipher_suite) | TLS ciphers used/supported by the HTTPS server for client negotiation. Only applies if starting an HTTPS server. | +| client.transport.minTLS | No | `TLSv1.2` | Minimum TLS version supported by the server. Only applies if starting an HTTPS server. Set to `TLSv1.2` for browser compatibility. | +| featureFlags | No | `{}` | Feature flag overrides to set. The configuration is as per the format specified [here](#feature-flags). These values will take precedence over any others provided. | +| hostname | No | '0.0.0.0' | The hostname the UI server will be bound to. | +| logging | No | TBD | Logging configuration settings. Format to be defined in https://github.com/strimzi/strimzi-ui/issues/24 | +| modules | No | Object - [enabled modules and configuration can be found here](../docs/Architecture.md#router-controller-data-pattern) | The modules which are either enabled or disabled. | +| port | No | 3000 | The port the UI server will be bound to. | +| proxy.transport.cert | No | If not provided, SSL certificate validation of the upstream admin server is disabled | CA certificate in PEM format of the backend admin server api requests are to be sent to. | +| proxy.hostname | Yes | N/A | The hostname of the admin server to send api requests to. | +| proxy.port | Yes | N/A | The port of the admin server to send api requests to. | +| proxy.authentication.type | No | `none` | What authentication strategy to use to authenticate users. See [the security section](#security) for details of the available options. | +| proxy.authentication.configuration | No | `{}` | Any additional configuration required for the provided authentication strategy `authentication.strategy` . See [the security section](#security) for details of the available options. | +| session.name | no | `strimzi-ui` | The name used to identify the session cookie | diff --git a/server/api/api.feature b/server/api/api.feature index 35eb1d5d..43b35a5b 100644 --- a/server/api/api.feature +++ b/server/api/api.feature @@ -5,26 +5,26 @@ Feature: api module Behaviours and capabilities provided by the api module Scenario: Proxies all requests made to /api to the configured backend - Given a 'api_only' server configuration - And I run an instance of the Strimzi-UI server - When I make a 'get' request to '/api/foo' - Then I make the expected proxy request and get the expected proxied response + Given a server with a 'api' configuration + And I run an instance of the Strimzi-UI server + When I make a 'get' request to '/api/foo' + Then I make the expected proxy request and get the expected proxied response Scenario: Proxies all requests made to /api to the securley configured backend - Given a 'api_secured_only' server configuration - And I run an instance of the Strimzi-UI server - When I make a 'get' request to '/api/foo' - Then I make the expected proxy request and get the expected proxied response + Given a server with a 'api_secured' configuration + And I run an instance of the Strimzi-UI server + When I make a 'get' request to '/api/foo' + Then I make the expected proxy request and get the expected proxied response Scenario: Handles errors from the proxied backend gracefully - Given a 'api_secured_only' server configuration - And the backend proxy returns an error response - And I run an instance of the Strimzi-UI server - When I make a 'get' request to '/api/foo' - Then I make the expected proxy request and get the expected proxied response + Given a server with a 'api_secured' configuration + And the backend proxy returns an error response + And I run an instance of the Strimzi-UI server + When I make a 'get' request to '/api/foo' + Then I make the expected proxy request and get the expected proxied response Scenario: Proxies all requests made to /api to the specified context root - Given a 'api_with_custom_context_root' server configuration - And I run an instance of the Strimzi-UI server - When I make a 'get' request to '/api/foo' - Then I make the expected proxy request and get the expected proxied response \ No newline at end of file + Given a server with a 'api_with_custom_context_root' configuration + And I run an instance of the Strimzi-UI server + When I make a 'get' request to '/api/foo' + Then I make the expected proxy request and get the expected proxied response \ No newline at end of file diff --git a/server/api/api.steps.ts b/server/api/api.steps.ts index 56c6f1aa..2eb2ed95 100644 --- a/server/api/api.steps.ts +++ b/server/api/api.steps.ts @@ -2,13 +2,13 @@ * Copyright Strimzi authors. * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). */ - +import { RequestHandler } from 'express'; import { createProxyServer } from 'http-proxy'; // without setting up a second server (with secure and insecure modes), the best way to simulate the proxying of calls is mocking them/verifying the api usage type mockProxyServerType = { - on: (event: string, handler: expressMiddleware) => void; - web: jest.Mock; + on: (event: string, handler: RequestHandler) => void; + web: jest.Mock; }; const placeholderProxyEvent = jest.fn(); @@ -55,7 +55,6 @@ import { stepWhichUpdatesWorld, stepWithWorld, } from 'test_common/commonServerSteps'; -import { expressMiddleware } from 'types'; Before(() => { createProxyServer.mockReturnValue(createMockServerFn(false)); diff --git a/server/api/router.ts b/server/api/router.ts index e4ba1d07..e6c9186d 100644 --- a/server/api/router.ts +++ b/server/api/router.ts @@ -16,7 +16,7 @@ const moduleName = 'api'; export const ApiModule: UIServerModule = { moduleName, - addModule: (logger, authFn, serverConfig) => { + addModule: (logger, { checkAuth }, serverConfig) => { const { proxy } = serverConfig; const { exit } = logger.entry('addModule', proxy); const { hostname, port, contextRoot, transport } = proxy; @@ -40,7 +40,9 @@ export const ApiModule: UIServerModule = { backendProxy.on('proxyReq', proxyStartHandler); backendProxy.on('proxyRes', proxyCompleteHandler); // proxy all requests post auth check - routerForModule.all('*', authFn, (req, res) => backendProxy.web(req, res)); + routerForModule.all('*', checkAuth, (req, res) => + backendProxy.web(req, res) + ); return exit({ mountPoint: '/api', routerForModule }); }, diff --git a/server/client/client.feature b/server/client/client.feature index 7ae11b19..8789f9df 100644 --- a/server/client/client.feature +++ b/server/client/client.feature @@ -62,6 +62,14 @@ Feature: client module | /protected.html | none | 200 | | / | none | 200 | + Examples: + | Asset | StatusCode | + | /index.html | 404 | + | /images/picture.svg | 404 | + | /doesnotexist.html | 404 | + | /someroute | 404 | + | /protected.html | 404 | + | / | 404 | Scenario Outline: With auth '' - Critical configuration is templated into index.html so the client can bootstrap Given a 'client_only' server configuration diff --git a/server/client/client.steps.ts b/server/client/client.steps.ts index 1510cef4..ec5cd0fa 100644 --- a/server/client/client.steps.ts +++ b/server/client/client.steps.ts @@ -3,10 +3,10 @@ * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). */ import merge from 'lodash.merge'; -import { And, Then, Fusion } from 'jest-cucumber-fusion'; +import { And, Fusion, Then } from 'jest-cucumber-fusion'; import { - stepWithWorld, stepWhichUpdatesWorld, + stepWithWorld, } from 'test_common/commonServerSteps'; And( @@ -24,23 +24,15 @@ And( ); And('There are files to serve', () => { - // NO_OP - the `client_only` configuration is already configured to serve fixture files + // NO_OP - the `client` configuration is already configured to serve fixture files }); Then( /I get the expected status code '(.+)' response/, - stepWithWorld(async (world, statusCode) => { + stepWithWorld((world, statusCode) => { + const expectedStatus = parseInt(statusCode as string); const { request } = world; - await request.then( - (res) => { - const { status } = res; - const expectedStatus = parseInt(statusCode as string); - expect(status).toBe(expectedStatus); - }, - (err) => { - throw err; - } - ); + return request.expect(expectedStatus); }) ); @@ -48,7 +40,7 @@ Then( 'the file is returned as with the expected configuration included', stepWithWorld(async (world) => { const { request, configuration } = world; - const configuredAuthType = configuration.authentication.strategy; + const configuredAuthType = configuration.proxy.authentication.type; await request.then( (res) => { diff --git a/server/client/controller.ts b/server/client/controller.ts index 4b7e62bd..fcdb3670 100644 --- a/server/client/controller.ts +++ b/server/client/controller.ts @@ -25,7 +25,6 @@ const publicFiles = [ 'images/*', 'fonts/*', 'favicon.ico', - 'index.html', 'main.css', 'main.bundle.js', 'main.bundle.js.gz', @@ -79,10 +78,9 @@ export const renderTemplate: (indexTemplate: string) => (req, res) => void = ( ) => (req, res) => { const { entry, debug } = res.locals.strimziuicontext.logger; const { exit } = entry('renderTemplate'); - const { authentication } = res.locals.strimziuicontext - .config as serverConfigType; + const { proxy } = res.locals.strimziuicontext.config as serverConfigType; const bootstrapConfigs = { - authType: authentication.strategy, + authType: proxy.authentication.type, }; debug('Templating bootstrap config containing %o', bootstrapConfigs); res.send( diff --git a/server/client/router.ts b/server/client/router.ts index f0f07691..58d9acc9 100644 --- a/server/client/router.ts +++ b/server/client/router.ts @@ -11,7 +11,7 @@ const moduleName = 'client'; export const ClientModule: UIServerModule = { moduleName, - addModule: (logger, authFn, serverConfig) => { + addModule: (logger, { checkAuth }, serverConfig) => { const { publicDir } = serverConfig.client; const { exit } = logger.entry('addModule', publicDir); const routerForModule = express.Router(); @@ -30,11 +30,12 @@ export const ClientModule: UIServerModule = { logger.debug(`Client has index.html to serve? ${hasIndexFile}`); // add the auth middleware to all non public files - protectedFiles.forEach((file) => routerForModule.get(`${file}`, authFn)); + protectedFiles.forEach((file) => routerForModule.get(`${file}`, checkAuth)); + routerForModule.get('/', checkAuth); // Direct request for index, serve it (behind auth check) hasIndexFile && - routerForModule.get('/index.html', authFn, renderTemplate(indexFile)); + routerForModule.get('/index.html', checkAuth, renderTemplate(indexFile)); // host all files from the client dir routerForModule.get( @@ -44,7 +45,8 @@ export const ClientModule: UIServerModule = { ); // If no match and we have an index, serve it (behind auth check) - hasIndexFile && routerForModule.get('*', authFn, renderTemplate(indexFile)); + hasIndexFile && + routerForModule.get('*', checkAuth, renderTemplate(indexFile)); return exit({ mountPoint: '/', routerForModule }); }, diff --git a/server/config/config.feature b/server/config/config.feature index 8b92f196..f85c13f2 100644 --- a/server/config/config.feature +++ b/server/config/config.feature @@ -5,13 +5,13 @@ Feature: config module Behaviours and capabilities provided by the config module Scenario: Returns with the expected response for a config call - Given a 'config_only' server configuration - And I run an instance of the Strimzi-UI server - When I make a 'getConfigAndFeatureFlagQuery' gql request to '/config' - Then I get the expected config response + Given a server with a 'config' configuration + And I run an instance of the Strimzi-UI server + When I make a 'getConfigAndFeatureFlagQuery' gql request to '/config' + Then I get the expected config response Scenario: Returns with the expected response for a config call when config and feature flag overrides are present in the server configuration - Given a 'config_only_with_config_overrides' server configuration - And I run an instance of the Strimzi-UI server - When I make a 'getConfigAndFeatureFlagQueryWithConfigOverrides' gql request to '/config' - Then I get the expected config response with the config overrides present \ No newline at end of file + Given a server with a 'config_with_overrides' configuration + And I run an instance of the Strimzi-UI server + When I make a 'getConfigAndFeatureFlagQueryWithConfigOverrides' gql request to '/config' + Then I get the expected config response with the config overrides present \ No newline at end of file diff --git a/server/config/router.ts b/server/config/router.ts index 8661abbc..33ac9915 100644 --- a/server/config/router.ts +++ b/server/config/router.ts @@ -13,7 +13,7 @@ const moduleName = 'config'; export const ConfigModule: UIServerModule = { moduleName, - addModule: (logger, authFn, config) => { + addModule: (logger, { checkAuth }, config) => { const { exit } = logger.entry('addModule'); const routerForModule = express.Router(); @@ -22,7 +22,7 @@ export const ConfigModule: UIServerModule = { }); routerForModule.use( - authFn, + checkAuth, bodyParser.json(), server.getMiddleware({ path: '/' }) ); diff --git a/server/core/README.md b/server/core/README.md index 6f7a6247..ea7a2cec 100644 --- a/server/core/README.md +++ b/server/core/README.md @@ -15,13 +15,13 @@ The core module will import and interact with all other modules to implement the The core module will import all modules' default export. The export is expected to contain two keys - `moduleName` and `addModule`, as per the `UIServerModule` interface. `addModule` is expected to be a function, which takes the following parameters: ``` -const { mountPoint, routerFromModule } = myModule(logGenerator, authMiddleware, serverConfig); +const { mountPoint, routerFromModule } = myModule(logGenerator, authFunctions, serverConfig); ``` Where: - `logGenerator` is a function which will return a logger to be used in `addModule` only to trace entry, exit and any helpful diagnostics while this module mounts -- `authMiddleware` is an express middleware function to be inserted/used when a module's routes require a user to be authenticated to access them +- `authFunctions` is an object containing authentication express middleware function to be inserted/used when a module's routes require a user to be authenticated to access them - `serverConfig` is the server's configuration at start up. If your module requires configuration at mount, it can be accessed here. This function is to return an object containing two items. The first is the context route this module will be mounted on (eg `/dev`). The second is an express [Router](https://expressjs.com/en/4x/api.html#router), which this module will have appended it's handlers to. diff --git a/server/core/app.ts b/server/core/app.ts index 1c210378..4ecaaa8c 100644 --- a/server/core/app.ts +++ b/server/core/app.ts @@ -6,13 +6,14 @@ import express from 'express'; import helmet from 'helmet'; import * as availableModules from './modules'; import { serverConfigType, UIServerModule } from 'types'; -import { authFunction } from 'placeholderFunctionsToReplace'; import expressSession, { SessionOptions } from 'express-session'; +import bodyParser from 'body-parser'; import { generateLogger, generateHttpLogger, STRIMZI_UI_REQUEST_ID_HEADER, } from 'logging'; +import { bootstrapAuthentication } from 'security'; export const returnExpress: ( getConfig: () => serverConfigType @@ -20,7 +21,7 @@ export const returnExpress: ( const logger = generateLogger('core'); const app = express(); - const { session } = getConfig(); + const { session: sessionConfig, proxy: proxyConfig } = getConfig(); // add helmet middleware app.use(helmet()); @@ -31,12 +32,15 @@ export const returnExpress: ( //Add session middleware const sessionOpts: SessionOptions = { secret: 'CHANGEME', //TODO replace with value from config https://github.com/strimzi/strimzi-ui/issues/111 - name: session.name, + name: sessionConfig.name, cookie: { maxAge: 1000 * 3600 * 24 * 30, //30 days as a starting point //TODO replace with value from config https://github.com/strimzi/strimzi-ui/issues/111 }, }; app.use(expressSession(sessionOpts)); + app.use(bodyParser.json()); + + const authentication = bootstrapAuthentication(app, proxyConfig); // for each module, call the function to add it to the routing table const routingTable = Object.values(availableModules).reduce( @@ -51,7 +55,7 @@ export const returnExpress: ( const config = getConfig(); const { mountPoint, routerForModule } = addModule( generateLogger(moduleName), - authFunction(config.authentication), + authentication, config ); logger.info(`Mounted module '${moduleName}' on '${mountPoint}'`); diff --git a/server/core/core.feature b/server/core/core.feature index f325c262..ab64cb29 100644 --- a/server/core/core.feature +++ b/server/core/core.feature @@ -5,31 +5,31 @@ Feature: core module Behaviours and capabilities provided by the core module Scenario: When making a call with no strimzi-ui header, one is added for later requests - Given a 'mockapi_only' server configuration + Given a server with a 'mockapi' configuration And I run an instance of the Strimzi-UI server When I make a request with no unique request header Then a unique request header is returned in the response Scenario: When making a call with a strimzi-ui header, that header is used in the request - Given a 'mockapi_only' server configuration + Given a server with a 'mockapi' configuration And I run an instance of the Strimzi-UI server When I make a request with a unique request header Then the unique request header sent is returned in the response Scenario: When making a call to the strimzi-ui server, the expected secuirty headers are present - Given a 'mockapi_only' server configuration + Given a server with a 'mockapi' configuration And I run an instance of the Strimzi-UI server When I make a 'get' request to '/api/test' Then all expected security headers are present Scenario: If two modules mount routes on the same mounting point, and one is disabled, the enabled module is invoked - Given a 'mockapi_only' server configuration + Given a server with a 'mockapi' configuration And I run an instance of the Strimzi-UI server When I make a 'get' request to '/api/test' Then the mockapi handler is called Scenario: When making a call to the strimzi-ui server, the expected session cookie is present - Given a 'mockapi_only' server configuration + Given a server with a 'mockapi' configuration And a session identifier of 'server-name' And I run an instance of the Strimzi-UI server When I make a 'get' request to '/' diff --git a/server/core/modules.ts b/server/core/modules.ts index bdf7b514..a4ea9266 100644 --- a/server/core/modules.ts +++ b/server/core/modules.ts @@ -3,7 +3,8 @@ * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). */ export * from 'api/index'; -export * from 'client/index'; export * from 'config/index'; export * from 'log/index'; export * from 'mockapi/index'; +export { SecurityModule } from 'security/index'; +export * from 'client/index'; diff --git a/server/log/log.feature b/server/log/log.feature index 426b7c6c..a6dbfa7a 100644 --- a/server/log/log.feature +++ b/server/log/log.feature @@ -5,21 +5,21 @@ Feature: log module Behaviours and capabilities provided by the log module Scenario: Returns with the expected HTTP response for a /log call - Given a 'log_only' server configuration - And I run an instance of the Strimzi-UI server - When I make a 'get' request to '/log' - Then I get the expected log response + Given a server with a 'log' configuration + And I run an instance of the Strimzi-UI server + When I make a 'get' request to '/log' + Then I get the expected log response Scenario: Sets up the WebSocket connection on /log call - Given a 'log_only' server configuration - And I run an instance of the Strimzi-UI server - And I enable WebSocket connections on the Strimzi-UI server - When I make a WebSocket connection request to '/log' - And I send a logging WebSocket message - And I send a logging WebSocket message without a clientLevel - And I send a logging WebSocket message that is not a JSON array - And I send an unparsable string logging WebSocket message - And I send a non-string logging WebSocket message - And I close the WebSocket - Then the WebSocket has received 5 messages - And the WebSocket is closed \ No newline at end of file + Given a server with a 'log' configuration + And I run an instance of the Strimzi-UI server + And I enable WebSocket connections on the Strimzi-UI server + When I make a WebSocket connection request to '/log' + And I send a logging WebSocket message + And I send a logging WebSocket message without a clientLevel + And I send a logging WebSocket message that is not a JSON array + And I send an unparsable string logging WebSocket message + And I send a non-string logging WebSocket message + And I close the WebSocket + Then the WebSocket has received 5 messages + And the WebSocket is closed \ No newline at end of file diff --git a/server/log/router.ts b/server/log/router.ts index 911de28a..c5ccfe40 100644 --- a/server/log/router.ts +++ b/server/log/router.ts @@ -16,12 +16,12 @@ const moduleName = 'log'; export const LogModule: UIServerModule = { moduleName, - addModule: (logger, authFn) => { + addModule: (logger, { checkAuth }) => { const { exit } = logger.entry('addModule'); const routerForModule = express.Router(); // implementation to follow - routerForModule.get('*', authFn, (req, res) => { + routerForModule.get('*', checkAuth, (req, res) => { const { isWs } = req as strimziUIRequestType; const { ws } = res as strimziUIResponseType; if (isWs) { diff --git a/server/mockapi/data.ts b/server/mockapi/data.ts index 034afa0a..5c63d0df 100644 --- a/server/mockapi/data.ts +++ b/server/mockapi/data.ts @@ -2,7 +2,17 @@ * Copyright Strimzi authors. * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). */ - +import { gql } from 'apollo-server-express'; // placeholder GQL schema for a topic/topic list - ideally to come from file -export const schema = - 'type Topic {name: String partitions: Int replicas: Int } type Query { topic(name: String): Topic topics: [Topic] } '; +export const schema = gql` + type Topic { + name: String + partitions: Int + replicas: Int + } + type Query { + topic(name: String): Topic + topics: [Topic] + clusterInfo: String + } +`; diff --git a/server/mockapi/mockapi.feature b/server/mockapi/mockapi.feature index 79b2a0cc..3513f9db 100644 --- a/server/mockapi/mockapi.feature +++ b/server/mockapi/mockapi.feature @@ -5,13 +5,13 @@ Feature: mockapi module Behaviours and capabilities provided by the mockapi module Scenario: Returns with the expected response for a mocked api call - Given a 'mockapi_only' server configuration - And I run an instance of the Strimzi-UI server - When I make a 'mockTopicsQuery' gql request to '/api' - Then I get the expected mockapi response + Given a server with a 'mockapi' configuration + And I run an instance of the Strimzi-UI server + When I make a 'mockTopicsQuery' gql request to '/api' + Then I get the expected mockapi response Scenario: Returns with the expected response for a call to the test endpoint - Given a 'mockapi_only' server configuration - And I run an instance of the Strimzi-UI server - When I make a 'get' request to '/api/test' - Then I get the expected mockapi test endpoint response \ No newline at end of file + Given a server with a 'mockapi' configuration + And I run an instance of the Strimzi-UI server + When I make a 'get' request to '/api/test' + Then I get the expected mockapi test endpoint response \ No newline at end of file diff --git a/server/placeholderFunctionsToReplace.ts b/server/placeholderFunctionsToReplace.ts deleted file mode 100644 index 459e5948..00000000 --- a/server/placeholderFunctionsToReplace.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Strimzi authors. - * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). - */ -// placeholder functions - to be replaced by actual implementation later - -import express from 'express'; -import { authenticationConfigType } from 'types'; - -// https://github.com/orgs/strimzi/projects/2#card-44265081 -// function which returns a piece of express middleware for a given auth strategy -const authFunction: ( - config: authenticationConfigType -) => ( - req: express.Request, - res: express.Response, - next: express.NextFunction -) => void = ({ strategy }) => { - switch (strategy) { - default: - case 'none': - return (req, res, next) => next(); - case 'scram': - case 'oauth': - return (req, res) => res.sendStatus(511); // if auth on, reject for sake of example. This is a middleware, akin to passport doing its checks. - } -}; - -export { authFunction }; diff --git a/server/security/bootstrap.feature b/server/security/bootstrap.feature new file mode 100644 index 00000000..105ca973 --- /dev/null +++ b/server/security/bootstrap.feature @@ -0,0 +1,34 @@ +# Copyright Strimzi authors. +# License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). +Feature: Bootstrapping passport + + Scenario: Check auth function when not authenticated + Given an Application + When I bootstrap passport with authentication type 'scram' + Then check auth redirects to '/auth/login' + + Scenario: Check auth function when authenticated - Scram + Given an Application + When I bootstrap passport with authentication type 'scram' + And the request is authenticated + Then check auth returns '200' + + Scenario: Check auth function - No auth + Given an Application + When I bootstrap passport with authentication type 'none' + Then check auth returns '200' + + Scenario: Logout function - Scram + Given an Application + When I bootstrap passport with authentication type 'scram' + Then logout removes the user + + Scenario: Logout function - No auth + Given an Application + When I bootstrap passport with authentication type 'none' + Then logout returns '200' + + Scenario: Unsupported auth type + Given an Application + When I bootstrap passport with authentication type 'unsupported' + Then an error is thrown diff --git a/server/security/bootstrap.steps.ts b/server/security/bootstrap.steps.ts new file mode 100644 index 00000000..276c4474 --- /dev/null +++ b/server/security/bootstrap.steps.ts @@ -0,0 +1,135 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +import { bootstrapPassport } from './bootstrap'; +import express from 'express'; +import { authenticationStrategies } from 'types'; +import { + stepWhichUpdatesWorld, + stepWithWorld, +} from 'test_common/commonServerSteps'; +import { When, Then, Fusion, And } from 'jest-cucumber-fusion'; +import request from 'supertest'; +import { Authentication } from 'security'; + +const proxyDefaults = { + hostname: '', + port: 0, + contextRoot: '/', + transport: {}, +}; + +When( + /^I bootstrap passport with authentication type '(\w+)'$/, + stepWhichUpdatesWorld((world, authType) => { + try { + const auth = bootstrapPassport(world.context.app as express.Application, { + authentication: { type: authType as authenticationStrategies }, + ...proxyDefaults, + }); + world.context.auth = auth; + } catch (e) { + world.context.error = e; + } + + return world; + }) +); + +And( + 'the request is authenticated', + stepWithWorld((world) => { + const app = world.context.app as express.Application; + app.get('*', (req, _res, next) => { + req.isAuthenticated = () => true; + return next(); + }); + }) +); + +Then( + /^check auth redirects to '(.+)'$/, + stepWithWorld((world, redirectUrl) => { + const app = world.context.app as express.Application; + const auth = world.context.auth as Authentication; + app.get('/test', auth.checkAuth, (_req, res) => { + res.send('success'); + }); + + return request(app) + .get('/test') + .expect(302) + .expect('Location', redirectUrl as string); + }) +); + +Then( + /^check auth returns '(\d+)'$/, + stepWithWorld((world) => { + const app = world.context.app as express.Application; + const auth = world.context.auth as Authentication; + app.get('/test', auth.checkAuth, (_req, res) => { + res.send('success'); + }); + return request(app).get('/test').expect(200); + }) +); + +Then( + /^logout returns '(\d+)'$/, + stepWithWorld((world) => { + const app = world.context.app as express.Application; + const auth = world.context.auth as Authentication; + app.get('/test', auth.logout, (_req, res) => { + res.send('success'); + }); + return request(app).get('/test').expect(200); + }) +); + +Then( + 'logout removes the user', + stepWithWorld((world) => { + const app = world.context.app as express.Application; + const auth = world.context.auth as Authentication; + const logout = jest.fn(); + app.use('*', (req, _res, next) => { + req.logout = logout; + next(); + }); + app.get('/test', auth.logout, (_req, res) => { + res.send('success'); + }); + return request(app) + .get('/test') + .expect(200) + .then(() => expect(logout).toHaveBeenCalled); + }) +); + +Then( + /^I bootstrap passport with authentication type '(\w+)'$/, + stepWhichUpdatesWorld((world, authType) => { + const auth = bootstrapPassport(world.context.app as express.Application, { + authentication: { type: authType as authenticationStrategies }, + ...proxyDefaults, + }); + + world.context.auth = auth; + return world; + }) +); + +Then( + 'an error is thrown', + stepWithWorld((world) => { + const { + context: { error }, + } = world; + + expect(error).toBeTruthy(); + }) +); + +Fusion('bootstrap.feature'); diff --git a/server/security/bootstrap.ts b/server/security/bootstrap.ts new file mode 100644 index 00000000..2887d829 --- /dev/null +++ b/server/security/bootstrap.ts @@ -0,0 +1,58 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +import { getStrategy } from './strategy/strategyFactory'; +import passport from 'passport'; +import { RequestHandler } from 'express'; +import { proxyConfigType, authenticationStrategies } from 'types'; +import { Application } from 'express'; +import { Authentication } from './types'; +import { apiRoot, scram } from './routeConfig'; +import { join } from 'path'; + +const noOp: RequestHandler = (_req, _res, next) => next(); +const noAuth = { + authenticate: noOp, + checkAuth: noOp, + logout: noOp, +}; + +export const bootstrapPassport = ( + app: Application, + config: proxyConfigType +): Authentication => { + const authenticationConfig = config.authentication; + + if (authenticationConfig.type === authenticationStrategies.NONE) { + return noAuth; + } + + app.use(passport.initialize()); + app.use(passport.session()); + + const authStrategy = getStrategy(config); + + passport.use(authStrategy.name, authStrategy.strategy); + + switch (authenticationConfig.type) { + case authenticationStrategies.SCRAM: { + passport.serializeUser((user, done) => done(null, user)); + passport.deserializeUser((user, done) => done(null, user)); + return { + authenticate: passport.authenticate(authStrategy.name), + checkAuth: (req, res, next) => { + return req.isAuthenticated() + ? next() + : res.redirect(join(apiRoot, scram.login)); + }, + logout: (req, _res, next) => { + req.logout(); + next(); + }, + }; + } + default: + throw new Error(`Unsupported type "${authenticationConfig.type}"`); + } +}; diff --git a/server/security/index.ts b/server/security/index.ts new file mode 100644 index 00000000..c69bb1ff --- /dev/null +++ b/server/security/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +export { bootstrapPassport as bootstrapAuthentication } from './bootstrap'; +export * from './types'; +export { SecurityModule } from './router'; diff --git a/server/security/routeConfig.ts b/server/security/routeConfig.ts new file mode 100644 index 00000000..fa808e75 --- /dev/null +++ b/server/security/routeConfig.ts @@ -0,0 +1,10 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +export const scram = { + login: '/login', + logout: '/logout', +}; + +export const apiRoot = '/auth'; diff --git a/server/security/router.ts b/server/security/router.ts new file mode 100644 index 00000000..89fd05dd --- /dev/null +++ b/server/security/router.ts @@ -0,0 +1,41 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +import express from 'express'; +import { authenticationStrategies, UIServerModule } from '../types'; +import { apiRoot, scram } from './routeConfig'; + +const moduleName = 'security'; + +export const SecurityModule: UIServerModule = { + moduleName, + addModule: (logger, auth, { proxy }) => { + const authentication = proxy.authentication; + const { exit } = logger.entry('addModule', authentication); + const routerForModule = express.Router(); + + switch (authentication.type) { + case authenticationStrategies.SCRAM: { + logger.info('Mouting SCRAM security routes'); + routerForModule.post(scram.login, auth.authenticate, (_req, res) => + res.send(200) + ); + routerForModule.get( + scram.login, + (_req, res) => res.send('This will later be the login page') //https://github.com/strimzi/strimzi-ui/issues/110 + ); + routerForModule.post(scram.logout, auth.logout, (_req, res) => + res.send(200) + ); + break; + } + case authenticationStrategies.NONE: { + //noop + break; + } + } + + return exit({ mountPoint: apiRoot, routerForModule }); + }, +}; diff --git a/server/security/security.feature b/server/security/security.feature new file mode 100644 index 00000000..e68c98a4 --- /dev/null +++ b/server/security/security.feature @@ -0,0 +1,62 @@ +# Copyright Strimzi authors. +# License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). +Feature: Security module + + Scenario: SCRAM - authenticate valid credentials + + Given a server with a 'security' configuration + And authentication type 'scram' is required + And I run an instance of the Strimzi-UI server + And the scram authentication accepts credentials + When I send credentials to endpoint '/auth/login' + Then I get the expected status code '200' response + + Scenario: SCRAM - authenticate invalid credentials + + Given a server with a 'security' configuration + And authentication type 'scram' is required + And I run an instance of the Strimzi-UI server + And the scram authentication rejects credentials + When I send credentials to endpoint '/auth/login' + Then I get the expected status code '401' response + + Scenario: SCRAM - login page + + Given a server with a 'security' configuration + And authentication type 'scram' is required + And I run an instance of the Strimzi-UI server + When I make a 'get' request to '/auth/login' + Then I get the expected status code '200' response and body 'This will later be the login page' + + + Scenario: SCRAM - logout + Given a server with a 'security' configuration + And authentication type 'scram' is required + And I run an instance of the Strimzi-UI server + And the scram authentication accepts credentials + When I make a 'post' request to '/auth/logout' + Then I get the expected status code '200' response + + Scenario: Off - authenticate + + Given a server with a 'security' configuration + And authentication type 'none' is required + And I run an instance of the Strimzi-UI server + When I send credentials to endpoint '/auth/login' + Then I get the expected status code '404' response + + Scenario: Off - login route + + Given a server with a 'security' configuration + And authentication type 'none' is required + And I run an instance of the Strimzi-UI server + When I send credentials to endpoint '/auth/login' + Then I get the expected status code '404' response + + Scenario: Off - logout + + Given a server with a 'security' configuration + And authentication type 'none' is required + And I run an instance of the Strimzi-UI server + When I make a 'post' request to '/auth/logout' + Then I get the expected status code '404' response diff --git a/server/security/security.steps.ts b/server/security/security.steps.ts new file mode 100644 index 00000000..7a8076d3 --- /dev/null +++ b/server/security/security.steps.ts @@ -0,0 +1,7 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +import { Fusion } from 'jest-cucumber-fusion'; +import 'test_common/commonServerSteps'; +Fusion('security.feature'); diff --git a/server/security/strategy/scram/scramAuthenticator.feature b/server/security/strategy/scram/scramAuthenticator.feature new file mode 100644 index 00000000..84dab5eb --- /dev/null +++ b/server/security/strategy/scram/scramAuthenticator.feature @@ -0,0 +1,28 @@ +# Copyright Strimzi authors. +# License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). +Feature: Scram Authenticator + + Scenario Outline: Accepts a valid username and password + Given an authentication endpoint of 'https://strimzi-admin' + And the authentication endpoint accepts username '' and password '' + When a verify callback is generated + And I call verify with username '' and password '' + Then the callback should return a user with username '' and a token + + Examples: + | username | password | + | test-user | test-pw | + + Scenario: Rejects an invalid username and password + Given an authentication endpoint of 'https://strimzi-admin' + And the authentication endpoint refuses any credentials + When a verify callback is generated + And I call verify with username 'username' and password 'password' + Then the callback should return 'false' + + Scenario: Rejects when unable to authenticate + Given an authentication endpoint of 'https://strimzi-admin' + And the authentication endpoint returns a server error + When a verify callback is generated + And I call verify with username 'username' and password 'password' + Then the callback should return an error \ No newline at end of file diff --git a/server/security/strategy/scram/scramAuthenticator.steps.ts b/server/security/strategy/scram/scramAuthenticator.steps.ts new file mode 100644 index 00000000..6bdbc28c --- /dev/null +++ b/server/security/strategy/scram/scramAuthenticator.steps.ts @@ -0,0 +1,139 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +import { When, Then, Fusion, Given, And } from 'jest-cucumber-fusion'; +import { + stepWhichUpdatesWorld, + stepWithWorld, +} from 'test_common/commonServerSteps'; + +import { createVerifyCallback } from './scramAuthenticator'; +import { VerifyFunction } from 'passport-local'; +import nock from 'nock'; + +let verify: VerifyFunction; + +Given( + /^an authentication endpoint of '(\S+)'$/, + stepWhichUpdatesWorld((world, endpoint) => { + const context = world.context; + context.endpoint = endpoint as string; + context.nock = nock(context.endpoint as string); + return { + ...world, + context, + }; + }) +); + +And( + /^the authentication endpoint accepts username '(\S+)' and password '(\S+)'$/, + stepWhichUpdatesWorld((world, username, password) => { + const token = `Basic ${Buffer.from(`${username}:${password}`).toString( + 'base64' + )}`; + const { context } = world; + context.token = token; + context.nock = (context.nock as nock.Scope) + .matchHeader('authentication', token) + .post('/') + .reply(200, {}); + return { + ...world, + context, + }; + }) +); + +And( + 'the authentication endpoint refuses any credentials', + stepWhichUpdatesWorld((world) => { + const { context } = world; + + context.nock = (context.nock as nock.Scope).post('/').reply(200, { + errors: [ + { + message: 'auth error', + }, + ], + }); + return { + ...world, + context, + }; + }) +); + +And( + 'the authentication endpoint returns a server error', + stepWhichUpdatesWorld((world) => { + const { context } = world; + + context.nock = (context.nock as nock.Scope).post('/').reply(500); + return { + ...world, + context, + }; + }) +); + +When( + 'a verify callback is generated', + stepWithWorld((world) => { + const { + context: { endpoint }, + } = world; + verify = createVerifyCallback(endpoint as string); + }) +); + +And( + /^I call verify with username '(\S+)' and password '(\S+)'$/, + stepWhichUpdatesWorld((world, username, password) => { + return new Promise((resolve) => { + verify(username as string, password as string, (error, user) => { + const context = world.context; + resolve({ + ...world, + context: { ...context, error, user }, + }); + }); + }); + }) +); + +Then( + /^the callback should return a user with username '(\S+)' and a token$/, + stepWithWorld((world, username) => { + const { + context: { error, user, token }, + } = world; + expect(error).toBeNull(); + expect(user).toEqual({ username, token }); + }) +); + +Then( + /^the callback should return '(\S+)'$/, + stepWithWorld((world, result) => { + const { + context: { error, user }, + } = world; + expect(error).toBeNull(); + expect(JSON.stringify(user)).toEqual(result); + }) +); + +Then( + 'the callback should return an error', + stepWithWorld((world) => { + const { + context: { error, user }, + } = world; + expect(error).toBeTruthy(); + expect(user).toBeNull(); + }) +); + +Fusion('scramAuthenticator.feature'); diff --git a/server/security/strategy/scram/scramAuthenticator.ts b/server/security/strategy/scram/scramAuthenticator.ts new file mode 100644 index 00000000..0c81d61d --- /dev/null +++ b/server/security/strategy/scram/scramAuthenticator.ts @@ -0,0 +1,43 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +import axios from 'axios'; +import { VerifyFunction } from 'passport-local'; +import { generateLogger } from 'logging'; + +const logger = generateLogger('ScramAuthenticator'); + +const createVerifyCallback = (endpoint: string): VerifyFunction => { + const { exit } = logger.entry('createVerifyCallback'); + + const verify = async (username, password, done) => { + const { exit } = logger.entry('createVerifyCallback - callback'); + const query = { query: 'query {clusterInfo}' }; + try { + const token = Buffer.from(`${username}:${password}`).toString('base64'); + const basicAuth = `Basic ${token}`; + const { data } = await axios.post(endpoint, query, { + //TODO HTTPS support + headers: { + Authentication: basicAuth, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + + if (data.errors) { + logger.error('Error in Admin server response', data.errors); + return exit(done(null, false)); + } + + return exit(done(null, { username, token: basicAuth })); + } catch (err) { + logger.error('Error with admin server', err); + return exit(done(err, null)); + } + }; + return exit(verify); +}; + +export { createVerifyCallback }; diff --git a/server/security/strategy/strategyFactory.feature b/server/security/strategy/strategyFactory.feature new file mode 100644 index 00000000..423fa90a --- /dev/null +++ b/server/security/strategy/strategyFactory.feature @@ -0,0 +1,13 @@ +# Copyright Strimzi authors. +# License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). +Feature: Strategy Factory + + Scenario Outline: Strategies are returned of the expected type + + When a strategy factory is asked for type '' + Then the returned strategy is of type '' + Examples: + | type | expected | + | scram | scram | + | none | none | + | unknown | none | \ No newline at end of file diff --git a/server/security/strategy/strategyFactory.steps.ts b/server/security/strategy/strategyFactory.steps.ts new file mode 100644 index 00000000..5f690c13 --- /dev/null +++ b/server/security/strategy/strategyFactory.steps.ts @@ -0,0 +1,44 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +import { When, Then, Fusion } from 'jest-cucumber-fusion'; +import { + stepWhichUpdatesWorld, + stepWithWorld, +} from 'test_common/commonServerSteps'; + +import { AuthenticationStrategy, getStrategy } from './strategyFactory'; +import { authenticationStrategies, proxyConfigType } from 'types'; + +When( + /^a strategy factory is asked for type '(.+)'$/, + stepWhichUpdatesWorld((world, type) => { + const context = world.context; + const config: proxyConfigType = { + authentication: { + type: type as authenticationStrategies, + }, + hostname: '', + port: 0, + contextRoot: '', + transport: {}, + }; + context.strategy = getStrategy(config); + return { + ...world, + context, + }; + }) +); + +Then( + /^the returned strategy is of type '(.+)'$/, + stepWithWorld((world, type) => { + const { context } = world; + const strategy = context.strategy as AuthenticationStrategy; + expect(strategy.name).toEqual(type as string); + }) +); + +Fusion('strategyFactory.feature'); diff --git a/server/security/strategy/strategyFactory.ts b/server/security/strategy/strategyFactory.ts new file mode 100644 index 00000000..5482820a --- /dev/null +++ b/server/security/strategy/strategyFactory.ts @@ -0,0 +1,39 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +import { Strategy } from 'passport'; +import { authenticationStrategies, proxyConfigType } from 'types'; +import { Strategy as LocalStrategy } from 'passport-local'; +import { createVerifyCallback as createSaslCallback } from './scram/scramAuthenticator'; + +export interface AuthenticationStrategy { + name: string; + strategy: Strategy; +} + +export const getStrategy = ( + config: proxyConfigType +): AuthenticationStrategy => { + const authConfig = config.authentication; + + const strategy = { + name: authConfig.type.toString(), + }; + switch (authConfig.type) { + case authenticationStrategies.SCRAM: { + const endpoint = `http://${config.hostname}:${config.port}${config.contextRoot}`; //TODO https support + return { + ...strategy, + strategy: new LocalStrategy(createSaslCallback(endpoint)), + }; + } + case authenticationStrategies.NONE: + default: { + return { + name: authenticationStrategies.NONE, + strategy: new Strategy(), + }; + } + } +}; diff --git a/server/security/types.ts b/server/security/types.ts new file mode 100644 index 00000000..64bbb605 --- /dev/null +++ b/server/security/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +import { RequestHandler } from 'express'; + +export interface Authentication { + authenticate: RequestHandler; + checkAuth: RequestHandler; + logout: RequestHandler; +} + +export interface User { + name: string; + accessToken: string; +} diff --git a/server/test_common/commonServerSteps.ts b/server/test_common/commonServerSteps.ts index f9a515c1..51c9e9ea 100644 --- a/server/test_common/commonServerSteps.ts +++ b/server/test_common/commonServerSteps.ts @@ -4,14 +4,15 @@ */ import request from 'supertest'; import { returnExpress } from 'core'; -import { mocked } from 'ts-jest/utils'; -import { Given, When, And, Then } from 'jest-cucumber-fusion'; +import nock from 'nock'; +import { Given, When, And, Then, CallBack } from 'jest-cucumber-fusion'; import { serverConfigType, strimziUIRequestType, strimziUIResponseType, + authenticationStrategies, } from 'types'; -import { getConfigForName } from './testConfigs'; +import { getConfigForName, enableModule } from './testConfigs'; import express from 'express'; import merge from 'lodash.merge'; import { @@ -22,8 +23,6 @@ import { requests } from './testGQLRequests'; import MockedWebSocket from 'ws'; jest.mock('ws'); -jest.mock('../placeholderFunctionsToReplace'); -import { authFunction } from '../placeholderFunctionsToReplace'; type supertestRequestType = request.SuperTest; @@ -54,28 +53,56 @@ beforeEach(() => { mocked(authFunction).mockReturnValue((_req, _res, next) => next()); }); -Given( - /a '(.+)' server configuration/, +const withModule: [RegExp, CallBack] = [ + /^a '(.+)' module$/, + stepWhichUpdatesWorld((world, module) => { + const { configuration } = world; + + return { + ...world, + configuration: enableModule( + module as string, + configuration as serverConfigType + ), + }; + }), +]; + +Given(...withModule); +And(...withModule); + +const withConfig: [RegExp, CallBack] = [ + /^a server with a '(.+)' configuration$/, stepWhichUpdatesWorld((world, config) => { return { ...world, configuration: getConfigForName(config as string), }; + }), +]; + +Given(...withConfig); +And(...withConfig); + +Given( + 'an Application', + stepWhichUpdatesWorld((world) => { + world.context.app = express(); + return world; }) ); And( /^'(\S+)' authentication is required$/, stepWhichUpdatesWorld((world, auth) => { - if ((auth as string) !== 'none') { - mocked(authFunction).mockReturnValue((_req, res) => res.sendStatus(511)); - } - const { configuration } = world; + const configuration = merge(world.configuration, { + proxy: { + authentication: { type: auth as authenticationStrategies }, + }, + }); return { ...world, - configuration: merge(configuration, { - authentication: { strategy: auth }, - }), + configuration, }; }) ); @@ -96,6 +123,18 @@ And('I am authenticated', () => { mocked(authFunction).mockReturnValue((_req, _res, next) => next()); }); +And( + 'all requests use the same session', + stepWhichUpdatesWorld((world) => { + const { app } = world; + return { + ...world, + app, + server: request.agent(app), + }; + }) +); + And( 'I enable WebSocket connections on the Strimzi-UI server', stepWhichUpdatesWorld((world) => { @@ -121,21 +160,30 @@ And( }) ); -When( +const httpRequest: [RegExp, CallBack] = [ /^I make a '(.+)' request to '(.+)'$/, - stepWhichUpdatesWorld((world, method, endpoint) => { + stepWhichUpdatesWorld(async (world, method, endpoint) => { const { server } = world; + if (world.request) { + await world.request; + } return { ...world, request: server[method as string](endpoint), }; - }) -); + }), +]; + +When.call(null, ...httpRequest); +And.call(null, ...httpRequest); When( /^I make a '(.+)' gql request to '(.+)'$/, - stepWhichUpdatesWorld((world, requestName, endpoint) => { + stepWhichUpdatesWorld(async (world, requestName, endpoint) => { const { server } = world; + if (world.request) { + await world.request; + } const query = requests[requestName as string] || {}; @@ -219,4 +267,56 @@ const getMockedWebSocket: () => MockedWebSocket = () => { return websocket; }; +Then( + /I get the expected status code '(.+)' response$/, + stepWithWorld(async (world, statusCode) => { + const expectedStatus = parseInt(statusCode as string); + const { request } = world; + return request.expect(expectedStatus); + }) +); + +Then( + /I get the expected status code '(.+)' response and body '(.+)'$/, + stepWithWorld(async (world, statusCode, response) => { + const expectedStatus = parseInt(statusCode as string); + const { request } = world; + return request.expect(expectedStatus, response as string); + }) +); + +And( + 'the scram authentication accepts credentials', + stepWithWorld((world) => { + const { hostname, contextRoot, port } = world.configuration.proxy; + nock(`http://${hostname}:${port}`).post(contextRoot).reply(200, {}); + }) +); +And( + 'the scram authentication rejects credentials', + stepWithWorld((world) => { + const { hostname, contextRoot, port } = world.configuration.proxy; + nock(`http://${hostname}:${port}`) + .post(contextRoot) + .reply(200, { errors: ['unauth'] }); + }) +); + +When( + /^I send credentials to endpoint '(.+)'$/, + stepWhichUpdatesWorld(async (world, endpoint) => { + const { server } = world; + if (world.request) { + await world.request; + } + return { + ...world, + request: server.post(endpoint as string).send({ + username: 'user', + password: 'password', + }), + }; + }) +); + export { stepWhichUpdatesWorld, stepWithWorld }; diff --git a/server/test_common/testConfigs.ts b/server/test_common/testConfigs.ts index 696b6241..06e4c8d6 100644 --- a/server/test_common/testConfigs.ts +++ b/server/test_common/testConfigs.ts @@ -18,25 +18,23 @@ const modules = { config: false, log: false, mockapi: false, + security: false, }; -const mockapiModuleConfig: () => serverConfigType = () => +const singleModule: (module: string) => serverConfigType = (module) => merge({}, defaultTestConfig(), { - modules: { ...modules, mockapi: true }, + modules: { ...modules, ...{ [module]: true } }, }); -const logModuleConfig: () => serverConfigType = () => - merge({}, defaultTestConfig(), { - modules: { ...modules, log: true }, - }); - -const configModuleConfig: () => serverConfigType = () => - merge({}, defaultTestConfig(), { - modules: { ...modules, config: true }, +const clientModuleConfig: () => serverConfigType = () => + merge(singleModule('client'), { + client: { + publicDir: resolve(__dirname, './__test_fixtures__/client'), + }, }); const configModuleWithConfigOverrides: () => serverConfigType = () => - merge({}, configModuleConfig(), { + merge(singleModule('config'), { client: { configOverrides: { version: '34.0.0', @@ -52,35 +50,25 @@ const configModuleWithConfigOverrides: () => serverConfigType = () => }, }); -const clientModuleConfig: () => serverConfigType = () => - merge({}, defaultTestConfig(), { - client: { - publicDir: resolve(__dirname, './__test_fixtures__/client'), - }, - modules: { ...modules, client: true }, - }); - const apiModuleConfig: () => serverConfigType = () => - merge({}, defaultTestConfig(), { + merge(singleModule('api'), { proxy: { hostname: 'test-backend', port: 3434, }, - modules: { ...modules, api: true }, }); const apiModuleConfigWithCustomContextRoot: () => serverConfigType = () => - merge({}, defaultTestConfig(), { + merge(singleModule('api'), { proxy: { hostname: 'test-backend', port: 3434, contextRoot: '/myCustomContextRoot', }, - modules: { ...modules, api: true }, }); const securedApiModuleConfig: () => serverConfigType = () => - merge(apiModuleConfig(), { + merge(singleModule('api'), { proxy: { transport: { cert: 'mock certificate', @@ -88,27 +76,30 @@ const securedApiModuleConfig: () => serverConfigType = () => }, }); +export const enableModule: ( + module: string, + config: serverConfigType +) => serverConfigType = (module, config) => + merge({}, config, { + modules: { [module]: true }, + }); + export const getConfigForName: (name: string) => serverConfigType = (name) => { switch (name) { - default: - case 'default': - case 'production': - return defaultTestConfig(); - case 'mockapi_only': - return mockapiModuleConfig(); - case 'log_only': - return logModuleConfig(); - case 'config_only': - return configModuleConfig(); - case 'config_only_with_config_overrides': - return configModuleWithConfigOverrides(); - case 'client_only': - return clientModuleConfig(); - case 'api_only': - return apiModuleConfig(); - case 'api_secured_only': - return securedApiModuleConfig(); - case 'api_with_custom_context_root': - return apiModuleConfigWithCustomContextRoot(); + case 'default': + case 'production': + return defaultTestConfig(); + case 'config_with_overrides': + return configModuleWithConfigOverrides(); + case 'client': + return clientModuleConfig(); + case 'api': + return apiModuleConfig(); + case 'api_secured': + return securedApiModuleConfig(); + case 'api_with_custom_context_root': + return apiModuleConfigWithCustomContextRoot(); + default: + return singleModule(name); } }; diff --git a/server/tsconfig.json b/server/tsconfig.json index a128f89f..38add7ca 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "module": "CommonJS", "outDir": "../js/server", - "baseUrl": "./", + "baseUrl": ".", "paths": { "ui-config/*": ["../config/*"] } diff --git a/server/types.ts b/server/types.ts index f947ed77..9759f1ec 100644 --- a/server/types.ts +++ b/server/types.ts @@ -7,15 +7,17 @@ import WebSocket from 'ws'; import { SecureVersion } from 'tls'; import { Level, Logger, LoggerOptions } from 'pino'; import { exposedClientType, exposedFeatureFlagsType } from 'ui-config/types'; +import { Authentication } from 'security'; -export type supportedAuthenticationStrategyTypes = 'none' | 'scram' | 'oauth'; +export enum authenticationStrategies { + NONE = 'none', + SCRAM = 'scram', +} -export type authenticationConfigType = { +export interface authenticationConfig { /** What authentication strategy to use to authenticate users */ - strategy: supportedAuthenticationStrategyTypes; - /** Any additional configuration required for the provided authentication strategy */ - configuration?: Record; -}; + type: authenticationStrategies; +} type sslCertificateType = { /** certificate in PEM format */ @@ -50,7 +52,7 @@ type moduleConfigType = { mockapi: boolean; }; -type proxyConfigType = { +export type proxyConfigType = { /** The Hostname of the backend server to send API requests to */ hostname: string; /** The port number of the backend server to send API requests to */ @@ -59,6 +61,8 @@ type proxyConfigType = { contextRoot: string; /** SSL transport configuration */ transport: sslCertificateType; + /** authentication configuration */ + authentication: authenticationConfig; }; type sessionConfigType = { @@ -67,8 +71,6 @@ type sessionConfigType = { }; export type serverConfigType = { - /** authentication configuration */ - authentication: authenticationConfigType; /** client (browser) facing configuration */ client: clientConfigType; /** feature flag configuration overrides (for both client and server) */ @@ -115,7 +117,7 @@ interface addModule { /** function called to add a module to the UI server */ ( mountLogger: entryExitLoggerType, - authFunction: expressMiddleware, + authentication: Authentication, configAtServerStart: serverConfigType ): { /** the root/mounting point for requests made to this module */ @@ -132,14 +134,6 @@ export type UIServerModule = { addModule: addModule; }; -export interface expressMiddleware { - /** typing of a general piece of express middleware */ - ( - req: express.Request, - res: express.Response, - next: express.NextFunction - ): void; -} /** the request object provided on UI server request. Core express request plus additions */ export type strimziUIRequestType = express.Request & { /** indicates this request is a websocket request (and that the response will have a ws object to interact with) */ diff --git a/test_common/jest_cucumber_support/commonTestTypes.ts b/test_common/jest_cucumber_support/commonTestTypes.ts index 0de3f6a3..862c5ba3 100644 --- a/test_common/jest_cucumber_support/commonTestTypes.ts +++ b/test_common/jest_cucumber_support/commonTestTypes.ts @@ -11,6 +11,9 @@ interface withWorldInterface { (callback: (world: T, ...others: Array) => T): ( ...others: Array ) => void; + (callback: (world: T, ...others: Array) => Promise): ( + ...others: Array + ) => void; (callback: (world: T, ...others: Array) => void): ( ...others: Array ) => void; diff --git a/test_common/jest_cucumber_support/index.ts b/test_common/jest_cucumber_support/index.ts index 41c84c14..bed6cd93 100644 --- a/test_common/jest_cucumber_support/index.ts +++ b/test_common/jest_cucumber_support/index.ts @@ -3,5 +3,5 @@ * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). */ /* eslint-disable */ -import { common_stepdefs } from './commonStepdefs'; +import './commonStepdefs'; import * as commonServerStepDefinitions from '../../server/test_common/commonServerSteps'; diff --git a/utils/dev_config/mockadmin.config.js b/utils/dev_config/mockadmin.config.js index 7876a65e..0436a969 100644 --- a/utils/dev_config/mockadmin.config.js +++ b/utils/dev_config/mockadmin.config.js @@ -9,9 +9,6 @@ const { const { mockadminServer } = devEnvValues; module.exports = { - authentication: { - strategy: 'none', - }, client: { transport: { ...mockAdminCertificates, @@ -31,6 +28,9 @@ module.exports = { }, proxy: { transport: {}, + authentication: { + type: 'none', + }, }, ...mockadminServer, }; diff --git a/utils/dev_config/server.dev.config.js b/utils/dev_config/server.dev.config.js index c1205fba..d6009e96 100644 --- a/utils/dev_config/server.dev.config.js +++ b/utils/dev_config/server.dev.config.js @@ -27,12 +27,16 @@ module.exports = { config: true, log: true, mockapi: false, + security: true, }, proxy: { ...mockadminServer, transport: { ...mockAdminCertificates, }, + authentication: { + type: 'none', + }, }, ...devServer, }; diff --git a/utils/dev_config/server.e2e.config.js b/utils/dev_config/server.e2e.config.js index c74771ca..1389acfb 100644 --- a/utils/dev_config/server.e2e.config.js +++ b/utils/dev_config/server.e2e.config.js @@ -27,6 +27,9 @@ module.exports = { transport: { ...mockAdminCertificates, }, + authentication: { + type: 'none', + }, }, ...devServer, }; diff --git a/utils/tooling/runtimeDevUtils.js b/utils/tooling/runtimeDevUtils.js index fd6cc23a..030a6369 100644 --- a/utils/tooling/runtimeDevUtils.js +++ b/utils/tooling/runtimeDevUtils.js @@ -17,6 +17,9 @@ const devEnvValues = { hostname: process.env.MA_HOSTNAME || 'localhost', port: process.env.MA_PORT || 9080, contextRoot: process.env.MA_CONTEXT_ROOT || '/api', + authentication: { + type: 'none', + }, }, // (development instance) server hostname and port devServer: { From 1a51212fa7562986d8b9934b5e1ee290187eeb96 Mon Sep 17 00:00:00 2001 From: Nic Townsend Date: Mon, 7 Dec 2020 16:05:14 +0000 Subject: [PATCH 4/4] feat: oidc authentication - passport strategy - new config for oidc routes Contributes to: strimzi/strimzi-ui#106 Signed-off-by: Nic Townsend --- package-lock.json | 112 +++++++------- package.json | 1 + server/README.md | 67 ++++++--- server/client/client.feature | 48 ++---- server/client/router.ts | 1 - server/core/app.ts | 4 +- server/main.ts | 4 +- server/security/bootstrap.steps.ts | 13 +- server/security/bootstrap.ts | 140 +++++++++++++++--- server/security/routeConfig.ts | 7 + server/security/router.ts | 14 +- server/security/security.feature | 14 +- .../strategy/oauth/oauthAuthenticator.ts | 28 ++++ .../security/strategy/strategyFactory.feature | 1 + .../strategy/strategyFactory.steps.ts | 49 ++++-- server/security/strategy/strategyFactory.ts | 42 ++++-- server/security/types.ts | 28 ++++ server/test_common/commonServerSteps.ts | 32 ++-- server/tsconfig.json | 3 +- server/types.ts | 9 ++ server/typing/express-session/index.d.ts | 10 ++ utils/dev_config/server.dev.config.js | 7 +- utils/dev_config/server.e2e.config.js | 14 ++ 23 files changed, 469 insertions(+), 179 deletions(-) create mode 100644 server/security/strategy/oauth/oauthAuthenticator.ts create mode 100644 server/typing/express-session/index.d.ts diff --git a/package-lock.json b/package-lock.json index 7a417634..6d944584 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8283,7 +8283,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "dev": true, "requires": { "safer-buffer": "~2.1.0" } @@ -8338,8 +8337,7 @@ "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, "assertion-error": { "version": "1.1.0", @@ -8450,14 +8448,12 @@ "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", - "dev": true + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.1.tgz", - "integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==", - "dev": true + "integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==" }, "axios": { "version": "0.21.0", @@ -9471,7 +9467,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "dev": true, "requires": { "tweetnacl": "^0.14.3" } @@ -10268,8 +10263,7 @@ "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", - "dev": true + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, "ccount": { "version": "1.0.5", @@ -11324,8 +11318,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "cors": { "version": "2.8.5", @@ -12261,7 +12254,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -12986,7 +12978,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "dev": true, "requires": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -14183,8 +14174,7 @@ "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, "extend-shallow": { "version": "3.0.2", @@ -14307,8 +14297,7 @@ "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "dev": true + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, "fast-deep-equal": { "version": "3.1.3", @@ -14739,8 +14728,7 @@ "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", - "dev": true + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, "fork-ts-checker-webpack-plugin": { "version": "4.1.6", @@ -15076,7 +15064,6 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -15462,14 +15449,12 @@ "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", - "dev": true + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" }, "har-validator": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "dev": true, "requires": { "ajv": "^6.12.3", "har-schema": "^2.0.0" @@ -16039,7 +16024,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -17042,8 +17026,7 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, "is-whitespace-character": { "version": "1.0.4", @@ -17098,8 +17081,7 @@ "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", - "dev": true + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, "istanbul-lib-coverage": { "version": "3.0.0", @@ -22074,8 +22056,7 @@ "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "dev": true + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, "jscodeshift": { "version": "0.6.4", @@ -22225,8 +22206,7 @@ "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", - "dev": true + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" }, "json-schema-traverse": { "version": "0.4.1", @@ -22251,8 +22231,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json3": { "version": "3.3.3", @@ -22293,7 +22272,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "dev": true, "requires": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -24819,11 +24797,15 @@ "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", "dev": true }, + "oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE=" + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "dev": true + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" }, "object-assign": { "version": "4.1.1", @@ -25430,6 +25412,17 @@ "passport-strategy": "1.x.x" } }, + "passport-openidconnect": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.0.2.tgz", + "integrity": "sha1-5Ij4vbOGyan9OckdWrjIgBVugVM=", + "requires": { + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "request": "^2.75.0", + "webfinger": "0.4.x" + } + }, "passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -25525,8 +25518,7 @@ "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", - "dev": true + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, "picomatch": { "version": "2.2.2", @@ -26697,8 +26689,7 @@ "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", - "dev": true + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" }, "pstree.remy": { "version": "1.1.8", @@ -28194,7 +28185,6 @@ "version": "2.88.2", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "dev": true, "requires": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -28222,7 +28212,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", @@ -28232,14 +28221,12 @@ "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", - "dev": true + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" }, "uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" } } }, @@ -29337,7 +29324,6 @@ "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", - "dev": true, "requires": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -29466,6 +29452,11 @@ "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", "dev": true }, + "step": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/step/-/step-0.0.6.tgz", + "integrity": "sha1-FD54SaXX0/SgiP4pr5SRUhbu7eI=" + }, "store2": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/store2/-/store2-2.12.0.tgz", @@ -31449,7 +31440,6 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, "requires": { "psl": "^1.1.28", "punycode": "^2.1.1" @@ -31696,7 +31686,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "dev": true, "requires": { "safe-buffer": "^5.0.1" } @@ -31704,8 +31693,7 @@ "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", - "dev": true + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, "type": { "version": "1.2.0", @@ -32432,7 +32420,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -32986,6 +32973,15 @@ "integrity": "sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==", "dev": true }, + "webfinger": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webfinger/-/webfinger-0.4.2.tgz", + "integrity": "sha1-NHem2XeZRhiWA5/P/GULc0aO520=", + "requires": { + "step": "0.0.x", + "xml2js": "0.1.x" + } + }, "webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", @@ -34088,6 +34084,14 @@ "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", "dev": true }, + "xml2js": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.1.14.tgz", + "integrity": "sha1-UnTmf1pkxfkpdM2FE54DMq3GuQw=", + "requires": { + "sax": ">=0.1.1" + } + }, "xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/package.json b/package.json index 757c30ac..e0d5be66 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "mustache": "^4.0.1", "passport": "^0.4.1", "passport-local": "^1.0.0", + "passport-openidconnect": "0.0.2", "pino": "^6.7.0", "pino-filter": "^1.0.0", "pino-http": "^5.3.0", diff --git a/server/README.md b/server/README.md index 7ecc266b..1941a5da 100644 --- a/server/README.md +++ b/server/README.md @@ -20,22 +20,51 @@ This directory contains all server code for the Strimzi UI - ie code which is re As described in [the configuration approach](../docs/Architecture.md#configuration-and-feature-flagging), the UI server's configuration is provided via a file, which is then watched at runtime for modification. This configuration file is expected to be called `server.config.json` (available in the same directory as the `node` executable is run from), but this can be configured at runtime via environment variable `configPath`, dictating a different path and file name. The file must be either valid JSON or JS. The server also hosts configuration for discovery by the client via the `config` module. The configuration options for the server provided in the previously mentioned configuration file are as follows: -| Configuration | Required | Default | Purpose | -| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| client.configOverrides | No | `{}` | Overrides to send to the client. See [client configuration for further details](#client-configuration). These values will take precedence over any others provided. | -| client.publicDir | No | `/dist/client` | The location of the built client to serve. | -| client.transport.cert | No | N/A - if one of `client.transport.cert` or `client.transport.key` are not provided, server will be HTTP | PEM certificate presented to browsers on connecting to the UI server. | -| client.transport.key | No | N/A - if one of `client.transport.cert` or `client.transport.key` are not provided, server will be HTTP | PEM certificate private key for the certificate provided in `client.transport.cert`. | -| client.transport.ciphers | No | default set from [node's tls module](https://nodejs.org/api/tls.html#tls_modifying_the_default_tls_cipher_suite) | TLS ciphers used/supported by the HTTPS server for client negotiation. Only applies if starting an HTTPS server. | -| client.transport.minTLS | No | `TLSv1.2` | Minimum TLS version supported by the server. Only applies if starting an HTTPS server. Set to `TLSv1.2` for browser compatibility. | -| featureFlags | No | `{}` | Feature flag overrides to set. The configuration is as per the format specified [here](#feature-flags). These values will take precedence over any others provided. | -| hostname | No | '0.0.0.0' | The hostname the UI server will be bound to. | -| logging | No | TBD | Logging configuration settings. Format to be defined in https://github.com/strimzi/strimzi-ui/issues/24 | -| modules | No | Object - [enabled modules and configuration can be found here](../docs/Architecture.md#router-controller-data-pattern) | The modules which are either enabled or disabled. | -| port | No | 3000 | The port the UI server will be bound to. | -| proxy.transport.cert | No | If not provided, SSL certificate validation of the upstream admin server is disabled | CA certificate in PEM format of the backend admin server api requests are to be sent to. | -| proxy.hostname | Yes | N/A | The hostname of the admin server to send api requests to. | -| proxy.port | Yes | N/A | The port of the admin server to send api requests to. | -| proxy.authentication.type | No | `none` | What authentication strategy to use to authenticate users. See [the security section](#security) for details of the available options. | -| proxy.authentication.configuration | No | `{}` | Any additional configuration required for the provided authentication strategy `authentication.strategy` . See [the security section](#security) for details of the available options. | -| session.name | no | `strimzi-ui` | The name used to identify the session cookie | +| Configuration | Required | Default | Purpose | +| ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| client.configOverrides | No | `{}` | Overrides to send to the client. See [client configuration for further details](#client-configuration). These values will take precedence over any others provided. | +| client.publicDir | No | `/dist/client` | The location of the built client to serve. | +| client.transport.cert | No | N/A - if one of `client.transport.cert` or `client.transport.key` are not provided, server will be HTTP | PEM certificate presented to browsers on connecting to the UI server. | +| client.transport.key | No | N/A - if one of `client.transport.cert` or `client.transport.key` are not provided, server will be HTTP | PEM certificate private key for the certificate provided in `client.transport.cert`. | +| client.transport.ciphers | No | default set from [node's tls module](https://nodejs.org/api/tls.html#tls_modifying_the_default_tls_cipher_suite) | TLS ciphers used/supported by the HTTPS server for client negotiation. Only applies if starting an HTTPS server. | +| client.transport.minTLS | No | `TLSv1.2` | Minimum TLS version supported by the server. Only applies if starting an HTTPS server. Set to `TLSv1.2` for browser compatibility. | +| featureFlags | No | `{}` | Feature flag overrides to set. The configuration is as per the format specified [here](#feature-flags). These values will take precedence over any others provided. | +| hostname | No | '0.0.0.0' | The hostname the UI server will be bound to. | +| logging | No | TBD | Logging configuration settings. Format to be defined in https://github.com/strimzi/strimzi-ui/issues/24 | +| modules | No | Object - [enabled modules and configuration can be found here](../docs/Architecture.md#router-controller-data-pattern) | The modules which are either enabled or disabled. | +| port | No | 3000 | The port the UI server will be bound to. | +| proxy.transport.cert | No | If not provided, SSL certificate validation of the upstream admin server is disabled | CA certificate in PEM format of the backend admin server api requests are to be sent to. | +| proxy.hostname | Yes | N/A | The hostname of the admin server to send api requests to. | +| proxy.port | Yes | N/A | The port of the admin server to send api requests to. | +| proxy.authentication | Yes | `{}` | What authentication strategy to use to authenticate users. See [the security section](#security) for details of the available options. | +| session.name | no | `strimzi-ui` | The name used to identify the session cookie | + +### Security + +#### None + +```js +{ + type: 'none'; +} +``` + +#### Scram + +```js +{ + type: 'scram'; +} +``` + +#### OAuth + +```js +{ + type: string //Must be "oauth", + callbackURL: string, //Absolute URL that the oauth server can redirect to. The path must be '/auth/callback', + discoveryURL: string, //Absolute URL to the well-known config endpoint on the OAUTH server + clientID : string, //client ID to identify this application, provided when registering with OAUTH server + clientSecret: string //client secret for this application, provided when registering with OAUTH server +} +``` diff --git a/server/client/client.feature b/server/client/client.feature index 8789f9df..3d00cfdc 100644 --- a/server/client/client.feature +++ b/server/client/client.feature @@ -5,13 +5,12 @@ Feature: client module Behaviours and capabilities provided by the client module Scenario Outline: With auth '' - If no asset can be served, the client module returns 404 - Given a 'client_only' server configuration + Given a server with a 'client' configuration And There are no files to serve And '' authentication is required And I run an instance of the Strimzi-UI server When I make a 'get' request to '' Then I get the expected status code '' response - Examples: | Asset | Auth | StatusCode | | /index.html | scram | 404 | @@ -34,27 +33,28 @@ Feature: client module | / | none | 404 | Scenario Outline: With auth '' - if assets can be served, the client module returns the appropriate return code for a request of - Given a 'client_only' server configuration + Given a server with a 'client' configuration + And a 'security' module And There are files to serve And '' authentication is required And I run an instance of the Strimzi-UI server When I make a 'get' request to '' Then I get the expected status code '' response - # if the route (not file) is not matched, we render index.html as the repsonse (200) + # if the route (not file) is not matched, we render index.html as the response (200) Examples: | Asset | Auth | StatusCode | - | /index.html | scram | 511 | + | /index.html | scram | 302 | | /images/picture.svg | scram | 200 | - | /doesnotexist.html | scram | 511 | - | /someroute | scram | 511 | - | /protected.html | scram | 511 | - | / | scram | 511 | - | /index.html | oauth | 511 | + | /doesnotexist.html | scram | 302 | + | /someroute | scram | 302 | + | /protected.html | scram | 302 | + | / | scram | 302 | + | /index.html | oauth | 302 | | /images/picture.svg | oauth | 200 | - | /doesnotexist.html | oauth | 511 | - | /someroute | oauth | 511 | - | /protected.html | oauth | 511 | - | / | oauth | 511 | + | /doesnotexist.html | oauth | 302 | + | /someroute | oauth | 302 | + | /protected.html | oauth | 302 | + | / | oauth | 302 | | /index.html | none | 200 | | /images/picture.svg | none | 200 | | /doesnotexist.html | none | 200 | @@ -62,25 +62,9 @@ Feature: client module | /protected.html | none | 200 | | / | none | 200 | - Examples: - | Asset | StatusCode | - | /index.html | 404 | - | /images/picture.svg | 404 | - | /doesnotexist.html | 404 | - | /someroute | 404 | - | /protected.html | 404 | - | / | 404 | - - Scenario Outline: With auth '' - Critical configuration is templated into index.html so the client can bootstrap - Given a 'client_only' server configuration + Scenario: Critical configuration is templated into index.html so the client can bootstrap + Given a server with a 'client' configuration And There are files to serve - And '' authentication is required - And I am authenticated And I run an instance of the Strimzi-UI server When I make a 'get' request to '/index.html' Then the file is returned as with the expected configuration included - Examples: - | Auth | - | scram | - | oauth | - | none | diff --git a/server/client/router.ts b/server/client/router.ts index 58d9acc9..abf2333a 100644 --- a/server/client/router.ts +++ b/server/client/router.ts @@ -31,7 +31,6 @@ export const ClientModule: UIServerModule = { // add the auth middleware to all non public files protectedFiles.forEach((file) => routerForModule.get(`${file}`, checkAuth)); - routerForModule.get('/', checkAuth); // Direct request for index, serve it (behind auth check) hasIndexFile && diff --git a/server/core/app.ts b/server/core/app.ts index 4ecaaa8c..0b853b4c 100644 --- a/server/core/app.ts +++ b/server/core/app.ts @@ -17,7 +17,7 @@ import { bootstrapAuthentication } from 'security'; export const returnExpress: ( getConfig: () => serverConfigType -) => express.Application = (getConfig) => { +) => Promise = async (getConfig) => { const logger = generateLogger('core'); const app = express(); @@ -40,7 +40,7 @@ export const returnExpress: ( app.use(expressSession(sessionOpts)); app.use(bodyParser.json()); - const authentication = bootstrapAuthentication(app, proxyConfig); + const authentication = await bootstrapAuthentication(app, proxyConfig); // for each module, call the function to add it to the routing table const routingTable = Object.values(availableModules).reduce( diff --git a/server/main.ts b/server/main.ts index 95cbb720..0e35af51 100644 --- a/server/main.ts +++ b/server/main.ts @@ -21,7 +21,7 @@ const errorHandler: (err: Error, ...others: unknown[]) => void = ( logger.info('Strimzi ui server initialising'); -loadConfig((loadedInitialConfig) => { +loadConfig(async (loadedInitialConfig) => { let config = loadedInitialConfig; logger.info( @@ -41,7 +41,7 @@ loadConfig((loadedInitialConfig) => { logger = generateLogger('main'); }, logger); // load config and update config value - const expressApp = returnExpress(() => config); + const expressApp = await returnExpress(() => config); const { cert, key, ciphers, minTLS } = config.client.transport; let server; diff --git a/server/security/bootstrap.steps.ts b/server/security/bootstrap.steps.ts index 276c4474..a7bac782 100644 --- a/server/security/bootstrap.steps.ts +++ b/server/security/bootstrap.steps.ts @@ -22,12 +22,15 @@ const proxyDefaults = { When( /^I bootstrap passport with authentication type '(\w+)'$/, - stepWhichUpdatesWorld((world, authType) => { + stepWhichUpdatesWorld(async (world, authType) => { try { - const auth = bootstrapPassport(world.context.app as express.Application, { - authentication: { type: authType as authenticationStrategies }, - ...proxyDefaults, - }); + const auth = await bootstrapPassport( + world.context.app as express.Application, + { + authentication: { type: authType as authenticationStrategies }, + ...proxyDefaults, + } + ); world.context.auth = auth; } catch (e) { world.context.error = e; diff --git a/server/security/bootstrap.ts b/server/security/bootstrap.ts index 2887d829..ed940a51 100644 --- a/server/security/bootstrap.ts +++ b/server/security/bootstrap.ts @@ -5,11 +5,21 @@ import { getStrategy } from './strategy/strategyFactory'; import passport from 'passport'; import { RequestHandler } from 'express'; -import { proxyConfigType, authenticationStrategies } from 'types'; -import { Application } from 'express'; -import { Authentication } from './types'; +import { proxyConfigType, authenticationStrategies, OAuthConfig } from 'types'; +import { Application, Request } from 'express'; +import { + Authentication, + OAuthEndpoints, + AuthOptions, + OAuthOptions, +} from './types'; +import axios from 'axios'; +import { generateLogger } from 'logging'; +import url from 'url'; import { apiRoot, scram } from './routeConfig'; -import { join } from 'path'; +import path from 'path'; + +const logger = generateLogger('bootstrap'); const noOp: RequestHandler = (_req, _res, next) => next(); const noAuth = { @@ -18,41 +28,135 @@ const noAuth = { logout: noOp, }; -export const bootstrapPassport = ( - app: Application, - config: proxyConfigType -): Authentication => { - const authenticationConfig = config.authentication; +const generateEndpoint = (proxyConfig: proxyConfigType) => { + const { exit } = logger.entry('generateEndpoint'); + return exit( + `http://${proxyConfig.hostname}:${proxyConfig.port}${proxyConfig.contextRoot}` + ); +}; + +const generateRedirectURL = (req: Request): string => { + const { + protocol, + hostname, + connection: { localPort }, + path, + } = req; + + return url.format({ + protocol, + hostname, + port: localPort, + pathname: path, + }); +}; + +const discoverConfiguration = async ( + discoveryURL: string +): Promise => { + const { exit } = logger.entry('discoverConfiguration'); + const res = await axios.get(discoveryURL); + const { + issuer, + authorization_endpoint, + token_endpoint, + userinfo_endpoint, + end_session_endpoint, + } = res.data; + + const config = { + issuer, + authorizationURL: authorization_endpoint, + tokenURL: token_endpoint, + userInfoURL: userinfo_endpoint, + logoutURL: end_session_endpoint, + }; + return exit(config); +}; +export const bootstrapPassport = async ( + app: Application, + proxy: proxyConfigType +): Promise => { + const authenticationConfig = proxy.authentication; if (authenticationConfig.type === authenticationStrategies.NONE) { return noAuth; } + const config: AuthOptions = await (async (type) => { + switch (type) { + case authenticationStrategies.SCRAM: { + return { + type, + endpoint: generateEndpoint(proxy), + }; + } + case authenticationStrategies.OAUTH: { + const oauth = authenticationConfig as OAuthConfig; + const { clientID, clientSecret, callbackURL } = oauth; + if (!oauth.discoveryURL) { + throw new Error( + 'OAUTH configuration is missing discovery url property' + ); + } + const endpoints = await discoverConfiguration(oauth.discoveryURL); + return { + type, + options: { + ...endpoints, + clientID, + clientSecret, + callbackURL, + }, + }; + } + default: + throw new Error(`Unsupported type "${authenticationConfig.type}"`); + } + })(authenticationConfig.type); + app.use(passport.initialize()); app.use(passport.session()); const authStrategy = getStrategy(config); passport.use(authStrategy.name, authStrategy.strategy); + passport.serializeUser((user, done) => done(null, user)); + passport.deserializeUser((user, done) => done(null, user)); switch (authenticationConfig.type) { case authenticationStrategies.SCRAM: { - passport.serializeUser((user, done) => done(null, user)); - passport.deserializeUser((user, done) => done(null, user)); return { + checkAuth: (req, res, next) => + req.isAuthenticated() + ? next() + : res.redirect(path.join(apiRoot, scram.login)), authenticate: passport.authenticate(authStrategy.name), + logout: (req, res) => { + req.logOut(); + return res.send(200); + }, + }; + } + case authenticationStrategies.OAUTH: { + const oauthConfig = config as OAuthOptions; + const logoutUrl = new URL(oauthConfig.options.logoutURL); + return { checkAuth: (req, res, next) => { - return req.isAuthenticated() + req.session.originalURL = generateRedirectURL(req); + req.isAuthenticated() ? next() - : res.redirect(join(apiRoot, scram.login)); + : passport.authenticate(authStrategy.name)(req, res, next); }, - logout: (req, _res, next) => { - req.logout(); - next(); + logout: (req, res) => { + const redirect_url = generateRedirectURL(req); + logoutUrl.search = new url.URLSearchParams({ + redirect_url, + }).toString(); + res.redirect(logoutUrl.toString()); }, + authenticate: passport.authenticate(authStrategy.name), }; } - default: - throw new Error(`Unsupported type "${authenticationConfig.type}"`); } }; diff --git a/server/security/routeConfig.ts b/server/security/routeConfig.ts index fa808e75..c1d466e6 100644 --- a/server/security/routeConfig.ts +++ b/server/security/routeConfig.ts @@ -4,6 +4,13 @@ */ export const scram = { login: '/login', +}; + +export const oauth = { + callback: '/callback', +}; + +export const common = { logout: '/logout', }; diff --git a/server/security/router.ts b/server/security/router.ts index 89fd05dd..eeba7b93 100644 --- a/server/security/router.ts +++ b/server/security/router.ts @@ -4,7 +4,7 @@ */ import express from 'express'; import { authenticationStrategies, UIServerModule } from '../types'; -import { apiRoot, scram } from './routeConfig'; +import { apiRoot, scram, oauth, common } from './routeConfig'; const moduleName = 'security'; @@ -25,11 +25,21 @@ export const SecurityModule: UIServerModule = { scram.login, (_req, res) => res.send('This will later be the login page') //https://github.com/strimzi/strimzi-ui/issues/110 ); - routerForModule.post(scram.logout, auth.logout, (_req, res) => + routerForModule.post(common.logout, auth.logout, (_req, res) => res.send(200) ); break; } + case authenticationStrategies.OAUTH: { + logger.info('Mouting OAUTH security routes'); + routerForModule.get(oauth.callback, auth.authenticate, (req, res) => { + const url = req.session.originalURL; + req.session.originalURL = '/'; + return res.redirect(url || '/'); + }); + routerForModule.get(common.logout, auth.logout); + break; + } case authenticationStrategies.NONE: { //noop break; diff --git a/server/security/security.feature b/server/security/security.feature index e68c98a4..8c112436 100644 --- a/server/security/security.feature +++ b/server/security/security.feature @@ -5,7 +5,7 @@ Feature: Security module Scenario: SCRAM - authenticate valid credentials Given a server with a 'security' configuration - And authentication type 'scram' is required + And 'scram' authentication is required And I run an instance of the Strimzi-UI server And the scram authentication accepts credentials When I send credentials to endpoint '/auth/login' @@ -14,7 +14,7 @@ Feature: Security module Scenario: SCRAM - authenticate invalid credentials Given a server with a 'security' configuration - And authentication type 'scram' is required + And 'scram' authentication is required And I run an instance of the Strimzi-UI server And the scram authentication rejects credentials When I send credentials to endpoint '/auth/login' @@ -23,7 +23,7 @@ Feature: Security module Scenario: SCRAM - login page Given a server with a 'security' configuration - And authentication type 'scram' is required + And 'scram' authentication is required And I run an instance of the Strimzi-UI server When I make a 'get' request to '/auth/login' Then I get the expected status code '200' response and body 'This will later be the login page' @@ -31,7 +31,7 @@ Feature: Security module Scenario: SCRAM - logout Given a server with a 'security' configuration - And authentication type 'scram' is required + And 'scram' authentication is required And I run an instance of the Strimzi-UI server And the scram authentication accepts credentials When I make a 'post' request to '/auth/logout' @@ -40,7 +40,7 @@ Feature: Security module Scenario: Off - authenticate Given a server with a 'security' configuration - And authentication type 'none' is required + And 'none' authentication is required And I run an instance of the Strimzi-UI server When I send credentials to endpoint '/auth/login' Then I get the expected status code '404' response @@ -48,7 +48,7 @@ Feature: Security module Scenario: Off - login route Given a server with a 'security' configuration - And authentication type 'none' is required + And 'none' authentication is required And I run an instance of the Strimzi-UI server When I send credentials to endpoint '/auth/login' Then I get the expected status code '404' response @@ -56,7 +56,7 @@ Feature: Security module Scenario: Off - logout Given a server with a 'security' configuration - And authentication type 'none' is required + And 'none' authentication is required And I run an instance of the Strimzi-UI server When I make a 'post' request to '/auth/logout' Then I get the expected status code '404' response diff --git a/server/security/strategy/oauth/oauthAuthenticator.ts b/server/security/strategy/oauth/oauthAuthenticator.ts new file mode 100644 index 00000000..42ffc42b --- /dev/null +++ b/server/security/strategy/oauth/oauthAuthenticator.ts @@ -0,0 +1,28 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +import { generateLogger } from 'logging'; +import { VerifyFunction } from 'passport-openidconnect'; + +const logger = generateLogger('OauthAuthenticator'); + +const createVerifyCallback = (): VerifyFunction => { + const { exit } = logger.entry('createVerifyCallback'); + + const verify = ( + _issuer, + username, + _profile, + accessToken, + _refreshToken, + done + ) => { + const { exit } = logger.entry('createVerifyCallback - callback'); + + return exit(done(null, { username, token: accessToken })); + }; + return exit(verify); +}; + +export { createVerifyCallback }; diff --git a/server/security/strategy/strategyFactory.feature b/server/security/strategy/strategyFactory.feature index 423fa90a..40a9aa60 100644 --- a/server/security/strategy/strategyFactory.feature +++ b/server/security/strategy/strategyFactory.feature @@ -9,5 +9,6 @@ Feature: Strategy Factory Examples: | type | expected | | scram | scram | + | oauth | oauth | | none | none | | unknown | none | \ No newline at end of file diff --git a/server/security/strategy/strategyFactory.steps.ts b/server/security/strategy/strategyFactory.steps.ts index 5f690c13..8e48384f 100644 --- a/server/security/strategy/strategyFactory.steps.ts +++ b/server/security/strategy/strategyFactory.steps.ts @@ -9,21 +9,50 @@ import { } from 'test_common/commonServerSteps'; import { AuthenticationStrategy, getStrategy } from './strategyFactory'; -import { authenticationStrategies, proxyConfigType } from 'types'; +import { authenticationStrategies } from 'types'; +import { + AuthOptions, + NoAuth, + OAuthOptions, + ScramOptions, +} from 'security/types'; + +jest.mock('passport-openidconnect'); +jest.mock('passport-local'); When( /^a strategy factory is asked for type '(.+)'$/, stepWhichUpdatesWorld((world, type) => { const context = world.context; - const config: proxyConfigType = { - authentication: { - type: type as authenticationStrategies, - }, - hostname: '', - port: 0, - contextRoot: '', - transport: {}, - }; + const config: AuthOptions = ((type: authenticationStrategies) => { + switch (type) { + case authenticationStrategies.SCRAM: + return { + type: authenticationStrategies.SCRAM, + endpoint: '', + } as ScramOptions; + case authenticationStrategies.OAUTH: + return { + type: authenticationStrategies.OAUTH, + options: { + issuer: '', + authorizationURL: '', + tokenURL: '', + userInfoURL: '', + logoutURL: '', + clientID: '', + clientSecret: '', + callbackURL: '', + }, + } as OAuthOptions; + case authenticationStrategies.NONE: + default: + return { + type: authenticationStrategies.NONE, + } as NoAuth; + } + })(type as authenticationStrategies); + context.strategy = getStrategy(config); return { ...world, diff --git a/server/security/strategy/strategyFactory.ts b/server/security/strategy/strategyFactory.ts index 5482820a..ed26fda5 100644 --- a/server/security/strategy/strategyFactory.ts +++ b/server/security/strategy/strategyFactory.ts @@ -3,37 +3,47 @@ * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). */ import { Strategy } from 'passport'; -import { authenticationStrategies, proxyConfigType } from 'types'; +import { authenticationStrategies } from 'types'; import { Strategy as LocalStrategy } from 'passport-local'; +import { Strategy as OAuth2Strategy } from 'passport-openidconnect'; import { createVerifyCallback as createSaslCallback } from './scram/scramAuthenticator'; +import { createVerifyCallback as createOauthCallback } from './oauth/oauthAuthenticator'; +import { AuthOptions, ScramOptions, OAuthOptions } from '../types'; +import { generateLogger } from 'logging'; export interface AuthenticationStrategy { name: string; strategy: Strategy; } -export const getStrategy = ( - config: proxyConfigType -): AuthenticationStrategy => { - const authConfig = config.authentication; +const logger = generateLogger('strategyFactory'); - const strategy = { - name: authConfig.type.toString(), - }; - switch (authConfig.type) { +export const getStrategy = (config: AuthOptions): AuthenticationStrategy => { + const { exit } = logger.entry('getStrategy', config); + switch (config.type) { case authenticationStrategies.SCRAM: { - const endpoint = `http://${config.hostname}:${config.port}${config.contextRoot}`; //TODO https support - return { - ...strategy, - strategy: new LocalStrategy(createSaslCallback(endpoint)), - }; + const scramConfig = config as ScramOptions; + return exit({ + name: config.type, + strategy: new LocalStrategy(createSaslCallback(scramConfig.endpoint)), + }); + } + case authenticationStrategies.OAUTH: { + const oAuthConfig = config as OAuthOptions; + return exit({ + name: oAuthConfig.type, + strategy: new OAuth2Strategy( + oAuthConfig.options, + createOauthCallback() + ), + }); } case authenticationStrategies.NONE: default: { - return { + return exit({ name: authenticationStrategies.NONE, strategy: new Strategy(), - }; + }); } } }; diff --git a/server/security/types.ts b/server/security/types.ts index 64bbb605..3aaaefe2 100644 --- a/server/security/types.ts +++ b/server/security/types.ts @@ -3,6 +3,7 @@ * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). */ import { RequestHandler } from 'express'; +import { authenticationStrategies } from 'types'; export interface Authentication { authenticate: RequestHandler; @@ -14,3 +15,30 @@ export interface User { name: string; accessToken: string; } + +export type AuthOptions = OAuthOptions | ScramOptions | NoAuth; + +export interface NoAuth { + type: authenticationStrategies.NONE; +} +export interface ScramOptions { + type: authenticationStrategies.SCRAM; + endpoint: string; +} + +export interface OAuthOptions { + type: authenticationStrategies.OAUTH; + options: OAuthEndpoints & { + clientID: string; + clientSecret: string; + callbackURL: string; + }; +} + +export interface OAuthEndpoints { + issuer: string; + authorizationURL: string; + tokenURL: string; + userInfoURL: string; + logoutURL: string; +} diff --git a/server/test_common/commonServerSteps.ts b/server/test_common/commonServerSteps.ts index 51c9e9ea..27607319 100644 --- a/server/test_common/commonServerSteps.ts +++ b/server/test_common/commonServerSteps.ts @@ -49,8 +49,6 @@ const { resetWorld, stepWhichUpdatesWorld, stepWithWorld } = worldGenerator( beforeEach(() => { resetWorld(); - jest.resetAllMocks(); - mocked(authFunction).mockReturnValue((_req, _res, next) => next()); }); const withModule: [RegExp, CallBack] = [ @@ -95,9 +93,29 @@ Given( And( /^'(\S+)' authentication is required$/, stepWhichUpdatesWorld((world, auth) => { + let authentication = { + type: auth as authenticationStrategies, + }; + + if ((auth as authenticationStrategies) === authenticationStrategies.OAUTH) { + const authServer = 'http://oauth-server.com'; + const oauthOptions = { + clientID: 'client', + clientSecret: 'secret', + callbackURL: 'http://callback.com', + discoveryURL: authServer, + }; + authentication = { ...authentication, ...oauthOptions }; + nock(authServer).get('/').reply(200, { + authorization_endpoint: authServer, + issuer: authServer, + token_endpoint: authServer, + end_session_endpoint: authServer, + }); + } const configuration = merge(world.configuration, { proxy: { - authentication: { type: auth as authenticationStrategies }, + authentication, }, }); return { @@ -109,8 +127,8 @@ And( And( 'I run an instance of the Strimzi-UI server', - stepWhichUpdatesWorld((world) => { - const app = returnExpress(() => world.configuration); + stepWhichUpdatesWorld(async (world) => { + const app = await returnExpress(() => world.configuration); return { ...world, app, @@ -119,10 +137,6 @@ And( }) ); -And('I am authenticated', () => { - mocked(authFunction).mockReturnValue((_req, _res, next) => next()); -}); - And( 'all requests use the same session', stepWhichUpdatesWorld((world) => { diff --git a/server/tsconfig.json b/server/tsconfig.json index 38add7ca..cc609f8a 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -8,5 +8,6 @@ "ui-config/*": ["../config/*"] } }, - "include": ["."] + "include": ["."], + "typeRoots": ["./typing", "../node_modules/@types"] } diff --git a/server/types.ts b/server/types.ts index 9759f1ec..04e6f7a8 100644 --- a/server/types.ts +++ b/server/types.ts @@ -12,6 +12,7 @@ import { Authentication } from 'security'; export enum authenticationStrategies { NONE = 'none', SCRAM = 'scram', + OAUTH = 'oauth', } export interface authenticationConfig { @@ -19,6 +20,14 @@ export interface authenticationConfig { type: authenticationStrategies; } +export interface OAuthConfig extends authenticationConfig { + type: authenticationStrategies.OAUTH; + discoveryURL: string; + clientID: string; + clientSecret: string; + callbackURL: string; +} + type sslCertificateType = { /** certificate in PEM format */ cert?: string; diff --git a/server/typing/express-session/index.d.ts b/server/typing/express-session/index.d.ts new file mode 100644 index 00000000..96eacc0f --- /dev/null +++ b/server/typing/express-session/index.d.ts @@ -0,0 +1,10 @@ +/* + * Copyright Strimzi authors. + * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). + */ +import 'express-session'; +declare module 'express-session' { + interface SessionData { + originalURL?: string; + } +} diff --git a/utils/dev_config/server.dev.config.js b/utils/dev_config/server.dev.config.js index d6009e96..ef178e28 100644 --- a/utils/dev_config/server.dev.config.js +++ b/utils/dev_config/server.dev.config.js @@ -35,7 +35,12 @@ module.exports = { ...mockAdminCertificates, }, authentication: { - type: 'none', + type: 'oauth', + discoveryURL: + 'http://localhost:8080/auth/realms/master/.well-known/openid-configuration', + clientID: 'test', + clientSecret: '54c6a7bd-7c0e-47f4-a5bd-55d4df515223', + callbackURL: 'http://localhost:3000/auth/callback', }, }, ...devServer, diff --git a/utils/dev_config/server.e2e.config.js b/utils/dev_config/server.e2e.config.js index 1389acfb..9234f766 100644 --- a/utils/dev_config/server.e2e.config.js +++ b/utils/dev_config/server.e2e.config.js @@ -22,6 +22,14 @@ module.exports = { translateTime: true, }, }, + modules: { + api: true, + client: true, + config: true, + log: true, + mockapi: false, + security: true, + }, proxy: { ...mockadminServer, transport: { @@ -29,6 +37,12 @@ module.exports = { }, authentication: { type: 'none', + // type: 'oauth', + // discoveryURL: + // 'http://localhost:8080/auth/realms/master/.well-known/openid-configuration', + // clientID: 'test', + // clientSecret: '09687ae6-4d4f-4147-85dd-11abf2122022', + // callbackURL: 'http://localhost:3000/auth/callback', }, }, ...devServer,