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/package-lock.json b/package-lock.json index 3fe468d1..e0cdf896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5725,6 +5725,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", @@ -7083,6 +7113,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", @@ -7092,6 +7131,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", @@ -7798,6 +7852,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", @@ -14564,6 +14626,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", @@ -23597,6 +23674,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", @@ -24518,6 +24607,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", @@ -24578,6 +24689,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", @@ -25733,6 +25849,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 e7673007..bda52aa9 100644 --- a/package.json +++ b/package.json @@ -45,16 +45,21 @@ "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", + "axios": "^0.21.0", + "body-parser": "^1.19.0", "compression-webpack-plugin": "^4.0.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": "^4.5.0", "http-proxy": "^1.18.1", @@ -68,6 +73,8 @@ "mini-css-extract-plugin": "^0.9.0", "mustache": "^4.0.1", "optimize-css-assets-webpack-plugin": "^5.0.4", + "passport": "^0.4.1", + "passport-local": "^1.0.0", "pino": "^6.7.0", "pino-filter": "^1.0.0", "pino-http": "^5.3.0", @@ -98,6 +105,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", @@ -126,6 +135,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.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..4d0d5783 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, serverConfig, { checkAuth }) => { 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 c50fc51f..ded2058a 100644 --- a/server/client/client.feature +++ b/server/client/client.feature @@ -5,44 +5,59 @@ 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 + Given a 'client_only' server configuration + And There are no files to serve + 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 | StatusCode | + | /index.html | 404 | + | /images/picture.svg | 404 | + | /doesnotexist.html | 404 | + | /someroute | 404 | + | /protected.html | 404 | + | / | 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: 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 type 'none' 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: If assets can be served without authentication, 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 type 'none' 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 | 200 | + | / | 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: If assets can be served with authentication, 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 type 'scram' 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 | 302 | + | /images/picture.svg | 200 | + | /doesnotexist.html | 404 | + | /someroute | 302 | + | /protected.html | 302 | + | / | 302 | diff --git a/server/client/client.steps.ts b/server/client/client.steps.ts index 1510cef4..7f140f5b 100644 --- a/server/client/client.steps.ts +++ b/server/client/client.steps.ts @@ -2,53 +2,45 @@ * Copyright Strimzi authors. * 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 merge from "lodash.merge"; +import { And, Fusion, Then } from "jest-cucumber-fusion"; import { - stepWithWorld, stepWhichUpdatesWorld, -} from 'test_common/commonServerSteps'; + stepWithWorld, +} from "test_common/commonServerSteps"; And( - 'There are no files to serve', + "There are no files to serve", stepWhichUpdatesWorld((world) => { return { ...world, configuration: merge(world.configuration, { client: { - publicDir: '/dir/that/does/not/exist', + publicDir: "/dir/that/does/not/exist", }, }), }; }) ); -And('There are files to serve', () => { +And("There are files to serve", () => { // NO_OP - the `client_only` 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); }) ); Then( - 'the file is returned as with the expected configuration included', + "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) => { @@ -67,4 +59,4 @@ Then( }) ); -Fusion('client.feature'); +Fusion("client.feature"); diff --git a/server/client/controller.ts b/server/client/controller.ts index 4b7e62bd..1d62b2c9 100644 --- a/server/client/controller.ts +++ b/server/client/controller.ts @@ -3,10 +3,10 @@ * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). */ -import { resolve, sep } from 'path'; -import { existsSync, readdirSync, readFileSync } from 'fs'; -import { serverConfigType } from 'types'; -import { render } from 'mustache'; +import { resolve, sep } from "path"; +import { existsSync, readdirSync, readFileSync } from "fs"; +import { serverConfigType } from "types"; +import { render } from "mustache"; // function to recursively get all files from a directory const getFilesInDirectory: (directory: string) => Array = (directory) => @@ -22,13 +22,12 @@ const getFilesInDirectory: (directory: string) => Array = (directory) => // mark a subset of files as public - this means any user can access them. These entries will be used in a regex - if the test passes, it will be considered public const publicFiles = [ - 'images/*', - 'fonts/*', - 'favicon.ico', - 'index.html', - 'main.css', - 'main.bundle.js', - 'main.bundle.js.gz', + "images/*", + "fonts/*", + "favicon.ico", + "main.css", + "main.bundle.js", + "main.bundle.js.gz", ]; export const getFiles: ( @@ -58,12 +57,12 @@ export const getFiles: ( [] as string[] ); - const hasIndexFile = allFilesInClientDirectory.includes('/index.html'); + const hasIndexFile = allFilesInClientDirectory.includes("/index.html"); const indexFile = hasIndexFile ? readFileSync(resolve(`${builtClientDir}${sep}index.html`), { - encoding: 'utf-8', + encoding: "utf-8", }) - : ''; + : ""; return { totalNumberOfFiles: allFilesInClientDirectory.length, @@ -78,13 +77,12 @@ export const renderTemplate: (indexTemplate: string) => (req, res) => void = ( indexTemplate ) => (req, res) => { const { entry, debug } = res.locals.strimziuicontext.logger; - const { exit } = entry('renderTemplate'); - const { authentication } = res.locals.strimziuicontext - .config as serverConfigType; + const { exit } = entry("renderTemplate"); + const { proxy } = res.locals.strimziuicontext.config as serverConfigType; const bootstrapConfigs = { - authType: authentication.strategy, + authType: proxy.authentication.type, }; - debug('Templating bootstrap config containing %o', bootstrapConfigs); + debug("Templating bootstrap config containing %o", bootstrapConfigs); res.send( exit( render(indexTemplate, { diff --git a/server/client/router.ts b/server/client/router.ts index 09f3df4a..a59a523c 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, serverConfig, { checkAuth }) => { const { publicDir } = serverConfig.client; const { exit } = logger.entry('addModule', publicDir); const routerForModule = express.Router(); @@ -30,7 +30,8 @@ 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); // return index.html, with configuration templated in hasIndexFile && @@ -45,9 +46,10 @@ export const ClientModule: UIServerModule = { // 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`) - ); + routerForModule.get(/^((?!\.).)+$/, (req, res) => { + logger.info('Redirecting to index'); + res.redirect(`/`); + }); return exit({ mountPoint: '/', routerForModule }); }, diff --git a/server/config/config.steps.ts b/server/config/config.steps.ts index 1d35b420..2ec6a02a 100644 --- a/server/config/config.steps.ts +++ b/server/config/config.steps.ts @@ -2,11 +2,11 @@ * Copyright Strimzi authors. * License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html). */ -import { Then, Fusion } from 'jest-cucumber-fusion'; -import { stepWithWorld } from 'test_common/commonServerSteps'; +import { Then, Fusion } from "jest-cucumber-fusion"; +import { stepWithWorld } from "test_common/commonServerSteps"; Then( - 'I get the expected config response', + "I get the expected config response", stepWithWorld((world) => { const { request } = world; return request.expect(200).expect((res) => { @@ -17,19 +17,19 @@ Then( // confirm for all three config types the generated type names are present - shows the schema generation and resolvers are working expect(client).not.toBeUndefined(); - expect(client._generatedTypeName).toBe('client'); + expect(client._generatedTypeName).toBe("client"); expect(server).not.toBeUndefined(); - expect(server._generatedTypeName).toBe('server'); + expect(server._generatedTypeName).toBe("server"); expect(featureFlags).not.toBeUndefined(); - expect(featureFlags._generatedTypeName).toBe('featureFlags'); + expect(featureFlags._generatedTypeName).toBe("featureFlags"); }); }) ); Then( - 'I get the expected config response with the config overrides present', + "I get the expected config response with the config overrides present", stepWithWorld((world) => { const { request } = world; return request.expect(200).expect((res) => { @@ -41,10 +41,10 @@ Then( // confirm for all values are as expected - values defined in testConfig.ts expect(client).not.toBeUndefined(); - expect(client.version).toBe('34.0.0'); + expect(client.version).toBe("34.0.0"); expect(server).not.toBeUndefined(); - expect(server._generatedTypeName).toBe('server'); + expect(server._generatedTypeName).toBe("server"); expect(featureFlags).not.toBeUndefined(); expect(featureFlags.client.Home.showVersion).toBe(false); // overwrite a value from config @@ -53,4 +53,4 @@ Then( }) ); -Fusion('config.feature'); +Fusion("config.feature"); diff --git a/server/config/router.ts b/server/config/router.ts index 8661abbc..b8b652a5 100644 --- a/server/config/router.ts +++ b/server/config/router.ts @@ -2,19 +2,19 @@ * 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 { UIServerModule } from 'types'; +import express from "express"; +import { UIServerModule } from "types"; -import bodyParser from 'body-parser'; -import { ApolloServer } from 'apollo-server-express'; -import { apolloConfig } from './controller'; +import bodyParser from "body-parser"; +import { ApolloServer } from "apollo-server-express"; +import { apolloConfig } from "./controller"; -const moduleName = 'config'; +const moduleName = "config"; export const ConfigModule: UIServerModule = { moduleName, - addModule: (logger, authFn, config) => { - const { exit } = logger.entry('addModule'); + addModule: (logger, config, { checkAuth }) => { + const { exit } = logger.entry("addModule"); const routerForModule = express.Router(); const server = new ApolloServer({ @@ -22,11 +22,11 @@ export const ConfigModule: UIServerModule = { }); routerForModule.use( - authFn, + checkAuth, bodyParser.json(), - server.getMiddleware({ path: '/' }) + server.getMiddleware({ path: "/" }) ); - return exit({ mountPoint: '/config', routerForModule }); + return exit({ mountPoint: "/config", routerForModule }); }, }; diff --git a/server/core/app.ts b/server/core/app.ts index 1c210378..2b0f3c1c 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,8 +55,8 @@ export const returnExpress: ( const config = getConfig(); const { mountPoint, routerForModule } = addModule( generateLogger(moduleName), - authFunction(config.authentication), - config + config, + authentication ); logger.info(`Mounted module '${moduleName}' on '${mountPoint}'`); return { ...acc, [moduleName]: { mountPoint, routerForModule } }; diff --git a/server/core/modules.ts b/server/core/modules.ts index bdf7b514..6d4dd63b 100644 --- a/server/core/modules.ts +++ b/server/core/modules.ts @@ -3,7 +3,9 @@ * 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'; +// WARNING - client must be exported last (it will register catch-all routes) +export * from 'client/index'; diff --git a/server/log/router.ts b/server/log/router.ts index 911de28a..1b579535 100644 --- a/server/log/router.ts +++ b/server/log/router.ts @@ -2,45 +2,45 @@ * 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 { Logger } from 'pino'; -import { Data } from 'ws'; +import express from "express"; +import { Logger } from "pino"; +import { Data } from "ws"; import { ClientLoggingEvent, strimziUIRequestType, strimziUIResponseType, UIServerModule, -} from 'types'; +} from "types"; -const moduleName = 'log'; +const moduleName = "log"; export const LogModule: UIServerModule = { moduleName, - addModule: (logger, authFn) => { - const { exit } = logger.entry('addModule'); + addModule: (logger, _config, { 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) { - ws.on('message', messageHandler(logger)); - ws.on('close', closeHandler(logger)); + ws.on("message", messageHandler(logger)); + ws.on("close", closeHandler(logger)); } else { // Return 426 Upgrade Required if this isn't a websocket request res.sendStatus(426); } }); - return exit({ mountPoint: '/log', routerForModule }); + return exit({ mountPoint: "/log", routerForModule }); }, }; const messageHandler: (logger: Logger) => (data: Data) => void = (logger) => ( data ) => { - if (typeof data === 'string') { + if (typeof data === "string") { try { JSON.parse(data).forEach((clientLogEvent: ClientLoggingEvent) => { if (clientLogEvent.clientLevel) { 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/placeholderFunctionsToReplace.ts b/server/placeholderFunctionsToReplace.ts index 459e5948..5bc5af44 100644 --- a/server/placeholderFunctionsToReplace.ts +++ b/server/placeholderFunctionsToReplace.ts @@ -4,26 +4,4 @@ */ // 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 }; +export {}; 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/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/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..08691b41 --- /dev/null +++ b/server/security/router.ts @@ -0,0 +1,40 @@ +/* + * 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, { proxy }, auth) => { + 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') + ); + 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..458197d0 --- /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 'security_only' server configuration + And authentication type 'scram' is required + And I run an instance of the Strimzi-UI server + And a user is valid + When I send credentials to endpoint '/auth/login' + Then I get the expected status code '200' response + + Scenario: SCRAM - authenticate invalid credentials + + Given a 'security_only' server configuration + And authentication type 'scram' is required + And I run an instance of the Strimzi-UI server + And a user is invalid + When I send credentials to endpoint '/auth/login' + Then I get the expected status code '401' response + + Scenario: SCRAM - login page + + Given a 'security_only' server 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 'security_only' server configuration + And authentication type 'scram' is required + And I run an instance of the Strimzi-UI server + And a user is valid + When I make a 'post' request to '/auth/logout' + Then I get the expected status code '200' response + + Scenario: Off - authenticate + + Given a 'security_only' server 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 'security_only' server 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 'security_only' server 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..9fbf7de2 --- /dev/null +++ b/server/security/security.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 { And } from 'jest-cucumber-fusion'; +import nock from 'nock'; +import { + stepWithWorld, + stepWhichUpdatesWorld, +} from 'test_common/commonServerSteps'; +import { When, Fusion } from 'jest-cucumber-fusion'; + +And( + 'a user is valid', + stepWithWorld((world) => { + const { hostname, contextRoot, port } = world.configuration.proxy; + nock(`http://${hostname}:${port}`).post(contextRoot).reply(200, {}); + }) +); +And( + 'a user is invalid', + 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((world, endpoint) => { + const { server } = world; + return { + ...world, + request: server.post(endpoint as string).send({ + username: 'user', + password: 'password', + }), + }; + }) +); + +Fusion('security.feature'); diff --git a/server/security/strategy/StrategyFactory.ts b/server/security/strategy/StrategyFactory.ts new file mode 100644 index 00000000..7afcb31b --- /dev/null +++ b/server/security/strategy/StrategyFactory.ts @@ -0,0 +1,35 @@ +/* + * 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; + switch (authConfig.type) { + case authenticationStrategies.SCRAM: { + const endpoint = `http://${config.hostname}:${config.port}${config.contextRoot}`; //TODO https support + return { + name: authConfig.type.toString(), + strategy: new LocalStrategy(createSaslCallback(endpoint)), + }; + } + case authenticationStrategies.NONE: + default: { + return { + name: 'none', + strategy: new Strategy(), + }; + } + } +}; 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/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 21f5fa12..ce082358 100644 --- a/server/test_common/commonServerSteps.ts +++ b/server/test_common/commonServerSteps.ts @@ -9,6 +9,7 @@ import { serverConfigType, strimziUIRequestType, strimziUIResponseType, + authenticationStrategies, } from 'types'; import { getConfigForName } from './testConfigs'; import express from 'express'; @@ -59,15 +60,25 @@ Given( }) ); -And( - 'Authentication is required', +Given( + 'an Application', stepWhichUpdatesWorld((world) => { - const { configuration } = world; + world.context.app = express(); + return world; + }) +); + +And( + /^authentication type '(\w+)' is required$/, + stepWhichUpdatesWorld((world, type) => { + const configuration = merge(world.configuration, { + proxy: { + authentication: { type: type as authenticationStrategies }, + }, + }); return { ...world, - configuration: merge(configuration, { - authentication: { strategy: 'oauth' }, - }), + configuration, }; }) ); @@ -207,4 +218,22 @@ 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); + }) +); + export { stepWhichUpdatesWorld, stepWithWorld }; diff --git a/server/test_common/testConfigs.ts b/server/test_common/testConfigs.ts index 696b6241..e54a45d5 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 enableModule: (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(enableModule('client'), { + client: { + publicDir: resolve(__dirname, './__test_fixtures__/client'), + }, }); const configModuleWithConfigOverrides: () => serverConfigType = () => - merge({}, configModuleConfig(), { + merge(enableModule('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(enableModule('api'), { proxy: { hostname: 'test-backend', port: 3434, }, - modules: { ...modules, api: true }, }); const apiModuleConfigWithCustomContextRoot: () => serverConfigType = () => - merge({}, defaultTestConfig(), { + merge(enableModule('api'), { proxy: { hostname: 'test-backend', port: 3434, contextRoot: '/myCustomContextRoot', }, - modules: { ...modules, api: true }, }); const securedApiModuleConfig: () => serverConfigType = () => - merge(apiModuleConfig(), { + merge(enableModule('api'), { proxy: { transport: { cert: 'mock certificate', @@ -90,25 +78,27 @@ const securedApiModuleConfig: () => serverConfigType = () => 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(); + default: + case 'default': + case 'production': + return defaultTestConfig(); + case 'mockapi_only': + return enableModule('mockapi'); + case 'log_only': + return enableModule('log'); + case 'config_only': + return enableModule('config'); + case 'config_only_with_config_overrides': + return configModuleWithConfigOverrides(); + case 'client_only': + return clientModuleConfig(); + case 'security_only': + return enableModule('security'); + case 'api_only': + return apiModuleConfig(); + case 'api_secured_only': + return securedApiModuleConfig(); + case 'api_with_custom_context_root': + return apiModuleConfigWithCustomContextRoot(); } }; 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..2f9327d5 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,8 +117,8 @@ interface addModule { /** function called to add a module to the UI server */ ( mountLogger: entryExitLoggerType, - authFunction: expressMiddleware, - configAtServerStart: serverConfigType + configAtServerStart: serverConfigType, + authentication: Authentication ): { /** the root/mounting point for requests made to this module */ mountPoint: string; @@ -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..ff7adac0 100644 --- a/utils/dev_config/server.dev.config.js +++ b/utils/dev_config/server.dev.config.js @@ -23,16 +23,20 @@ module.exports = { }, modules: { api: true, - client: false, + client: true, 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: {