From 60ee6002018a3e58a83c9fff97cb97d7823214ee Mon Sep 17 00:00:00 2001 From: Csaky Date: Fri, 17 May 2024 13:34:20 -0700 Subject: [PATCH 1/7] Set-up backend api and auth --- app/app.ts | 4 + app/package-lock.json | 305 +++++++++++++++++++++++++++ app/package.json | 3 + app/src/interfaces/IExpress.ts | 18 ++ app/src/middleware/authentication.ts | 64 ++++++ app/src/routes/v1/index.ts | 18 ++ app/src/types/CurrentUser.ts | 5 + 7 files changed, 417 insertions(+) create mode 100644 app/src/interfaces/IExpress.ts create mode 100644 app/src/middleware/authentication.ts create mode 100644 app/src/routes/v1/index.ts create mode 100644 app/src/types/CurrentUser.ts diff --git a/app/app.ts b/app/app.ts index 3106a9b9..8112b1b5 100644 --- a/app/app.ts +++ b/app/app.ts @@ -12,6 +12,7 @@ import { name as appName, version as appVersion } from './package.json'; import { DEFAULTCORS } from './src/components/constants'; import { getLogger, httpLogger } from './src/components/log'; import { getGitRevision, readIdpList } from './src/components/utils'; +import v1Router from './src/routes/v1'; import type { Request, Response } from 'express'; @@ -92,6 +93,9 @@ appRouter.get('/api', (_req: Request, res: Response): void => { } }); +// v1 Router +appRouter.use(config.get('server.apiPath'), v1Router); + // Host the static frontend assets // This route assumes being executed from '/sbin' appRouter.use('/', express.static(join(__dirname, '../dist'))); diff --git a/app/package-lock.json b/app/package-lock.json index 3b052ffd..9f057857 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -14,6 +14,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.11.28", "api-problem": "^9.0.2", "axios": "^1.6.8", @@ -23,6 +24,8 @@ "express": "^4.18.3", "express-winston": "^4.2.0", "helmet": "^7.1.0", + "joi": "^17.13.1", + "jsonwebtoken": "^9.0.2", "ts-node": "^10.9.2", "winston": "^3.12.0", "winston-transport": "^4.7.0" @@ -893,6 +896,19 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1484,6 +1500,24 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1684,6 +1718,14 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2584,6 +2626,11 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3120,6 +3167,14 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7355,6 +7410,18 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "17.13.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.1.tgz", + "integrity": "sha512-vaBlIKCyo4FCUtCm7Eu4QZd/q02bWcxfUO6YSXAZOWF6gzcLBeba8kwotUdYJjDLW8Cz8RywsSOqiNJZW0mNvg==", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7414,6 +7481,43 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.1.tgz", + "integrity": "sha512-f/vbBsu+fOiYt+lmwZV0rVwJScl46HppnOA1ZvIuBWKOTlllpyJ3bfVax76/OrhCH38dyxoDIA8K7uB963IYgA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", @@ -7427,6 +7531,25 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -7486,6 +7609,36 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7498,6 +7651,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/logform": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.0.tgz", @@ -10487,6 +10645,19 @@ "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true }, + "@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, "@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -10935,6 +11106,24 @@ "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", "dev": true }, + "@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, + "@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, "@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -11135,6 +11324,14 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/jsonwebtoken": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "requires": { + "@types/node": "*" + } + }, "@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -11775,6 +11972,11 @@ "node-int64": "^0.4.0" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -12180,6 +12382,14 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -15329,6 +15539,18 @@ } } }, + "joi": { + "version": "17.13.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.1.tgz", + "integrity": "sha512-vaBlIKCyo4FCUtCm7Eu4QZd/q02bWcxfUO6YSXAZOWF6gzcLBeba8kwotUdYJjDLW8Cz8RywsSOqiNJZW0mNvg==", + "requires": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15373,6 +15595,35 @@ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" }, + "jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "dependencies": { + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "semver": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.1.tgz", + "integrity": "sha512-f/vbBsu+fOiYt+lmwZV0rVwJScl46HppnOA1ZvIuBWKOTlllpyJ3bfVax76/OrhCH38dyxoDIA8K7uB963IYgA==" + } + } + }, "jsx-ast-utils": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", @@ -15383,6 +15634,25 @@ "object.assign": "^4.1.3" } }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -15430,6 +15700,36 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -15442,6 +15742,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "logform": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.0.tgz", diff --git a/app/package.json b/app/package.json index 9c83ac2e..2ab3eb52 100644 --- a/app/package.json +++ b/app/package.json @@ -32,6 +32,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.11.28", "api-problem": "^9.0.2", "axios": "^1.6.8", @@ -41,6 +42,8 @@ "express": "^4.18.3", "express-winston": "^4.2.0", "helmet": "^7.1.0", + "joi": "^17.13.1", + "jsonwebtoken": "^9.0.2", "ts-node": "^10.9.2", "winston": "^3.12.0", "winston-transport": "^4.7.0" diff --git a/app/src/interfaces/IExpress.ts b/app/src/interfaces/IExpress.ts new file mode 100644 index 00000000..4377289b --- /dev/null +++ b/app/src/interfaces/IExpress.ts @@ -0,0 +1,18 @@ +import * as core from 'express-serve-static-core'; + +import type { CurrentUser } from '../types/CurrentUser'; + +interface Query extends core.Query {} + +interface Params extends core.ParamsDictionary {} + +export interface Request

extends core.Request { + currentUser?: CurrentUser; + params: P; + query: Q; + body: B; +} + +export interface Response extends core.Response {} + +export interface NextFunction extends core.NextFunction {} diff --git a/app/src/middleware/authentication.ts b/app/src/middleware/authentication.ts new file mode 100644 index 00000000..e3d5cd29 --- /dev/null +++ b/app/src/middleware/authentication.ts @@ -0,0 +1,64 @@ +// @ts-expect-error api-problem lacks a defined interface; code still works fine +import Problem from 'api-problem'; +import config from 'config'; +import jwt from 'jsonwebtoken'; + +import type { CurrentUser } from '../types'; +import type { NextFunction, Request, Response } from '../interfaces/IExpress'; + +/** + * @function _spkiWrapper + * Wraps an SPKI key with PEM header and footer + * @param {string} spki The PEM-encoded Simple public-key infrastructure string + * @returns {string} The PEM-encoded SPKI with PEM header and footer + */ +export const _spkiWrapper = (spki: string) => `-----BEGIN PUBLIC KEY-----\n${spki}\n-----END PUBLIC KEY-----`; + +/** + * @function currentUser + * Injects a currentUser object to the request if there exists valid authentication artifacts. + * Subsequent logic should check `req.currentUser.authType` for authentication method if needed. + * @param {Request} req Express request object + * @param {Response} res Express response object + * @param {NextFunction} next The next callback function + * @returns {function} Express middleware function + * @throws The error encountered upon failure + */ +export const currentUser = async (req: Request, res: Response, next: NextFunction) => { + const authorization = req.get('Authorization'); + const currentUser: CurrentUser = { + tokenPayload: null + }; + + if (authorization) { + try { + const bearerToken = authorization.substring(7); + let isValid: string | jwt.JwtPayload; + + if (config.has('server.oidc.authority') && config.has('server.oidc.publicKey')) { + const publicKey: string = config.get('server.oidc.publicKey'); + const pemKey = publicKey.startsWith('-----BEGIN') ? publicKey : _spkiWrapper(publicKey); + isValid = jwt.verify(bearerToken, pemKey, { issuer: config.get('server.oidc.authority') }); + } else { + throw new Error( + 'OIDC environment variables `SERVER_OIDC_AUTHORITY` and `SERVER_OIDC_PUBLICKEY` must be defined' + ); + } + + if (isValid) { + currentUser.tokenPayload = typeof isValid === 'object' ? isValid : jwt.decode(bearerToken); + } else { + throw new Error('Invalid authorization token'); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + return next(new Problem(403, { detail: err.message, instance: req.originalUrl })); + } + } + + // Inject currentUser data into request + req.currentUser = Object.freeze(currentUser); + + // Continue middleware + next(); +}; diff --git a/app/src/routes/v1/index.ts b/app/src/routes/v1/index.ts new file mode 100644 index 00000000..07aef37a --- /dev/null +++ b/app/src/routes/v1/index.ts @@ -0,0 +1,18 @@ +import { currentUser } from '../../middleware/authentication'; +import express from 'express'; + +import { emailValidator } from '../../validators'; +import emailController from '../../controllers/email'; + +import type { NextFunction, Request, Response } from '../../interfaces/IExpress'; + +const router = express.Router(); + +// incoming request (to backend) requires valid JWT of current user of front end app +router.use(currentUser); + +router.post('/email', emailValidator.mergeSchema, (req: Request, res: Response, next: NextFunction): void => { + emailController.send(req, res, next); +}); + +export default router; diff --git a/app/src/types/CurrentUser.ts b/app/src/types/CurrentUser.ts new file mode 100644 index 00000000..4886492a --- /dev/null +++ b/app/src/types/CurrentUser.ts @@ -0,0 +1,5 @@ +import jwt from 'jsonwebtoken'; + +export type CurrentUser = { + tokenPayload: string | jwt.JwtPayload | null; +}; From ac84b0f1c6dbd6e0f496a9a039eecdbc3a2489a2 Mon Sep 17 00:00:00 2001 From: Csaky Date: Fri, 17 May 2024 13:35:41 -0700 Subject: [PATCH 2/7] Backend CHES Email integration route, controller, service, validation --- app/src/components/constants.ts | 6 +++ app/src/controllers/email.ts | 31 ++++++++++++ app/src/middleware/validation.ts | 31 ++++++++++++ app/src/services/email.ts | 84 ++++++++++++++++++++++++++++++++ app/src/types/Email.ts | 14 ++++++ app/src/types/index.ts | 2 + app/src/validators/common.ts | 7 +++ app/src/validators/email.ts | 27 ++++++++++ app/src/validators/index.ts | 1 + 9 files changed, 203 insertions(+) create mode 100644 app/src/controllers/email.ts create mode 100644 app/src/middleware/validation.ts create mode 100644 app/src/services/email.ts create mode 100644 app/src/types/Email.ts create mode 100644 app/src/types/index.ts create mode 100644 app/src/validators/common.ts create mode 100644 app/src/validators/email.ts create mode 100644 app/src/validators/index.ts diff --git a/app/src/components/constants.ts b/app/src/components/constants.ts index 8b8e152f..5fd9581b 100644 --- a/app/src/components/constants.ts +++ b/app/src/components/constants.ts @@ -5,3 +5,9 @@ export const DEFAULTCORS = Object.freeze({ /** Set true to dynamically set Access-Control-Allow-Origin based on Origin */ origin: true }); + +/** + * Generic email regex modified to require domain of at least 2 characters + * @see {@link https://emailregex.com/} + */ +export const EMAIL_REGEX = '^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]{2,})+$'; diff --git a/app/src/controllers/email.ts b/app/src/controllers/email.ts new file mode 100644 index 00000000..7b5cfbfb --- /dev/null +++ b/app/src/controllers/email.ts @@ -0,0 +1,31 @@ + +import config from 'config'; +import emailService from '../services/email'; + +import type { NextFunction, Request, Response } from '../interfaces/IExpress'; +import { Email } from '../types'; + +const controller = { + /** + * @function send + * Send email using CHES API + * https://ches.api.gov.bc.ca/api/v1/docs#tag/EmailMerge/operation/postMerge + */ + send: async ( + req: Request, + res: Response, + next: NextFunction + ) => { + try { + req.body.from = config.get('server.ches.from'); + req.body.bodyType = 'html'; + const { data, status } = await emailService.emailMerge(req.body); + + res.status(status).json(data); + } catch (e: unknown) { + next(e); + } + } +}; + +export default controller; diff --git a/app/src/middleware/validation.ts b/app/src/middleware/validation.ts new file mode 100644 index 00000000..80a0e850 --- /dev/null +++ b/app/src/middleware/validation.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// @ts-expect-error api-problem lacks a defined interface; code still works fine +import Problem from 'api-problem'; +import type { NextFunction, Request, Response } from '../interfaces/IExpress'; + +/** + * @function validator + * Performs express request validation against a specified `schema` + * @param {object} schema An object containing Joi validation schema definitions + * @returns {function} Express middleware function + * @throws The error encountered upon failure + */ +export const validate = (schema: object) => { + return (req: Request, _res: Response, next: NextFunction) => { + const validationErrors = Object.entries(schema) + .map(([prop, def]) => { + const result = def.validate((req as any)[prop], { abortEarly: false })?.error; + return result ? [prop, result?.details] : undefined; + }) + .filter((error) => !!error) + .map((x) => x as Array>); + + if (Object.keys(validationErrors).length) { + throw new Problem(422, { + detail: validationErrors.flatMap((groups) => groups[1]?.map((error: any) => error?.message)).join('; '), + instance: req.originalUrl, + errors: Object.fromEntries(validationErrors) + }); + } else next(); + }; +}; diff --git a/app/src/services/email.ts b/app/src/services/email.ts new file mode 100644 index 00000000..28ddac55 --- /dev/null +++ b/app/src/services/email.ts @@ -0,0 +1,84 @@ +import axios from 'axios'; +import config from 'config'; + +import type { AxiosInstance } from 'axios'; +import type { Email } from '../types'; + +/** + * @function getToken + * Gets Auth token using CHES client credentials + * @returns + */ +async function getToken() { + const response = await axios({ + method: 'POST', + url: config.get('server.ches.tokenUrl'), + data: { + grant_type: 'client_credentials', + client_id: config.get('server.ches.clientId'), + client_secret: config.get('server.ches.clientSecret') + }, + headers: { + 'Content-type': 'application/x-www-form-urlencoded' + }, + withCredentials: true + }); + return response.data.access_token; +} +/** + * @function chesAxios + * Returns an Axios instance with Authorization header + * @param {AxiosRequestConfig} options Axios request config options + * @returns {AxiosInstance} An axios instance + */ +function chesAxios(): AxiosInstance { + // Create axios instance + const chesAxios = axios.create({ + baseURL: config.get('server.ches.apiPath'), + timeout: 10000 + }); + // Add bearer token + chesAxios.interceptors.request.use(async (config) => { + const token = await getToken(); + const auth = token ? `Bearer ${token}` : ''; + config.headers['Authorization'] = auth; + return config; + }); + return chesAxios; +} + +const service = { + /** + * @function emailMerge + * Sends emails with CHES service + * https://ches.api.gov.bc.ca/api/v1/docs#tag/EmailMerge/operation/postMerge + * @param emailData + * @returns Axios response status and data + */ + emailMerge: async (emailData: Email) => { + try { + const { data, status } = await chesAxios().post('/emailMerge', emailData, { + headers: { + 'Content-Type': 'application/json' + }, + maxContentLength: Infinity, + maxBodyLength: Infinity + }); + return { data, status }; + } catch (e: unknown) { + if (axios.isAxiosError(e)) { + return { + data: e.response?.data.error_description, + status: e.response ? e.response.status : 500 + }; + } else { + return { + data: 'Error sending email', + status: 500 + }; + } + } + }, +}; + +export default service; diff --git a/app/src/types/Email.ts b/app/src/types/Email.ts new file mode 100644 index 00000000..fc3739be --- /dev/null +++ b/app/src/types/Email.ts @@ -0,0 +1,14 @@ +export type Email = { + bcc?: Array; + bodyType: string; + body: string; + cc?: Array; + delayTS?: number; + encoding?: string; + from: string; + priority?: string; + subject: string; + to: Array; + tag?: string; + attachments?: Array; +}; diff --git a/app/src/types/index.ts b/app/src/types/index.ts new file mode 100644 index 00000000..119a765d --- /dev/null +++ b/app/src/types/index.ts @@ -0,0 +1,2 @@ +export type { CurrentUser } from './CurrentUser'; +export type { Email } from './Email'; diff --git a/app/src/validators/common.ts b/app/src/validators/common.ts new file mode 100644 index 00000000..8f8162c0 --- /dev/null +++ b/app/src/validators/common.ts @@ -0,0 +1,7 @@ +import Joi from 'joi'; + +import { EMAIL_REGEX } from '../components/constants'; + +export const emailJoi = Joi.string().pattern(new RegExp(EMAIL_REGEX)).max(255); + +export const uuidv4 = Joi.string().guid({ version: 'uuidv4' }); diff --git a/app/src/validators/email.ts b/app/src/validators/email.ts new file mode 100644 index 00000000..4dc9a7e5 --- /dev/null +++ b/app/src/validators/email.ts @@ -0,0 +1,27 @@ +import Joi from 'joi'; + +import { emailJoi } from './common'; +import { validate } from '../middleware/validation'; + +const schema = { + + mergeSchema: { + body: Joi.object().keys({ + body: Joi.string().required(), + subject: Joi.string().required(), + contexts: Joi.array().items( + Joi.object().keys({ + to: Joi.array().items(emailJoi).required(), + context: Joi.object().keys({ + token: Joi.string().required(), + }) + }) + ) + }) + }, + +}; + +export default { + mergeSchema: validate(schema.mergeSchema) +}; diff --git a/app/src/validators/index.ts b/app/src/validators/index.ts new file mode 100644 index 00000000..052610a1 --- /dev/null +++ b/app/src/validators/index.ts @@ -0,0 +1 @@ +export { default as emailValidator } from './email'; From b101cb8e454d16d0de7e66b2cf4d2911ed150de9 Mon Sep 17 00:00:00 2001 From: Csaky Date: Fri, 17 May 2024 13:37:33 -0700 Subject: [PATCH 3/7] Send Invite notification send invite if scoping to a user email template calls backend and CHES --- .../src/components/common/InviteButton.vue | 108 ++++++++------ frontend/src/services/interceptors.ts | 33 +++++ frontend/src/services/inviteService.ts | 135 +++++++++++++++--- 3 files changed, 217 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/common/InviteButton.vue b/frontend/src/components/common/InviteButton.vue index 9710091f..6b0fb44a 100644 --- a/frontend/src/components/common/InviteButton.vue +++ b/frontend/src/components/common/InviteButton.vue @@ -1,6 +1,6 @@

- {{ obj?.name || bucket?.bucketName }} + {{ resourceType === 'object' ? resource?.name : resource?.bucketName }}

{ {

{{ (props.labelText) }} Invite

Make invite available for

@@ -246,12 +266,11 @@ onMounted(() => {

Restrict to user's email

-
+ +
- { placeholder="Enter email" required type="email" - class="ml-5 mr-8" + class="mt-2 max-w-30rem" /> -
-
+ The Invite will be emailed to this person +
+
-
+ +

{ + const authService = new AuthService(); + const user = await authService.getUser(); + if (!!user && !user.expired) { + cfg.headers.Authorization = `Bearer ${user.access_token}`; + } + return Promise.resolve(cfg); + }, + (error: Error) => { + return Promise.reject(error); + } + ); + + return instance; +} + /** * @function comsAxios * Returns an Axios instance for the COMS API diff --git a/frontend/src/services/inviteService.ts b/frontend/src/services/inviteService.ts index 676de49c..7998da44 100644 --- a/frontend/src/services/inviteService.ts +++ b/frontend/src/services/inviteService.ts @@ -1,26 +1,127 @@ -import { comsAxios } from './interceptors'; +import { appAxios, comsAxios } from './interceptors'; + +import type { COMSObject, Bucket, User } from '@/types'; const PATH = 'permission/invite'; export default { + + /** + * @function createInvites + * Create an invite url with the COMS api + * if incomming emails then send email notifications with unique invite links + * @param {string} resourceType either 'object' or 'bucket' + * @param {COMSObject | Bucket } resource the COMS object or bucket record + * @param {User | null} currentUser current user sending the invite + * @param {Array} emails array of email adddresses for invitees + * @param {string} expiresAt timestamp for invite token expiry + * @param {Array} permCodes array of permCodes for the invite + * + * @typedef {object} Invite + * @property {string} email - invitee's email + * @property {string} token - invite token + * + * @returns {Array} array of invite email / token pairs + */ + async createInvites( + resourceType: string, + resource: any, + currentUser: any, + emails: Array, + expiresAt?: number, + permCodes?: Array + ) { + + const inviteData ={ + bucketId: resourceType === 'bucket' ? resource?.bucketId : undefined, + objectId: resourceType === 'object' ? resource.id : undefined, + expiresAt: expiresAt, + permCodes: permCodes + }; + + // if emails param exists + if(emails && emails.length > 0){ + // create COMS invites + const invites = await Promise.all( + emails.map(async e => { + const token = (await comsAxios().post(`${PATH}`, { + ...inviteData, + email: e, + })).data; + return { email: e, token: token}; + }) + ); + // send invite email notifications + await this.emailInvites(resourceType, resource, currentUser, invites); + return invites; + } + // else no emails provided so make an 'open' invite + else { + const token = (await comsAxios().post(`${PATH}`, inviteData)).data; + return [{ token: token }]; + } + }, + /** - * @function createInvite - * Post an Invite, exclusive or on bucketId and objectId - * @param {string} bucketId - * @param {string} objectId - * @param {string} email to be used for access - * @param {string} expiration timestamp for token - * @returns {string} uuid token of the invite - * @returns {array} permCodes token of the invite + * @function emailInvites + * Semd email to each invitee containing a link to the resource + * each email needs a unique invite link url so use CHES merge feature + * ref: https://ches.api.gov.bc.ca/api/v1/docs#tag/EmailMerge/operation/postMerge * + * @param {string} resourceType eg bucket or object + * @param {COMSObject | Bucket } resource COMS object or bucket + * @param {User | null} currentUser current user creating the invite + * @param {Array} invites array of email adddresses + * @returns {Promise} CHES TransactionId */ - createInvite(bucketId?: string, email?: string, expiresAt?: number, objectId?: string, permCodes?: Array) { - return comsAxios().post(`${PATH}`, { - bucketId: bucketId || undefined, - email: email || undefined, - expiresAt: expiresAt || undefined, - objectId: objectId || undefined, - permCodes: permCodes || undefined - }); + emailInvites(resourceType: string, resource: any, currentUser: any, invites: any){ + try { + // build template + let resourceName, subject, body; + const currentUserEmail = `${currentUser.email}`; + // alternate templates depending if resource is a file or a folder + if (resourceType === 'object') { + resourceName = resource.name; + subject = `You have been invited to access ${resourceName} on BCBox`; + body = `

+

${currentUserEmail} invited you to access a file on BCBox

+

Here's a link to access the file that ${currentUserEmail} shared with you:

`; + } + else if (resourceType === 'bucket') { + resourceName = resource.bucketName; + subject = `You have been invited to access ${resourceName} on BCBox`; + body = `

+

${currentUserEmail} invited you to access a folder on BCBox

\n +

Here's a link to access the folder that ${currentUserEmail} shared with you:

`; + } + body = body + ` + + ${resourceName } + +

+ + This invite will only work for you and people with existing access. If you do not recognize the sender, do not click on the link above. Only open links that you are expecting from a known sender. +

+ Learn more about BCBox + `; + + // define email data matching the structure required by CHES api + const emailData:any = { + contexts: invites.map((invite: any) => { + return { + to: [ invite.email ], + context: { + token: invite.token + } + }; + }), + subject: subject, + body: body + }; + return appAxios().post('email', emailData); + } catch(err) { + console.error(`Failed to send Invite notification: ${err}`); + + } }, /** From dd5a3da8e6cc36707e46c44c0d6f3f93e96fa6e2 Mon Sep 17 00:00:00 2001 From: Csaky Date: Fri, 17 May 2024 16:03:33 -0700 Subject: [PATCH 4/7] fix deployment --- app/src/services/email.ts | 1 + charts/bcbox/Chart.yaml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/services/email.ts b/app/src/services/email.ts index 28ddac55..5d0eedc2 100644 --- a/app/src/services/email.ts +++ b/app/src/services/email.ts @@ -74,6 +74,7 @@ const service = { } else { return { data: 'Error sending email', + e, status: 500 }; } diff --git a/charts/bcbox/Chart.yaml b/charts/bcbox/Chart.yaml index 4be78f56..8a314a66 100644 --- a/charts/bcbox/Chart.yaml +++ b/charts/bcbox/Chart.yaml @@ -3,7 +3,7 @@ name: bcbox # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.0.15 +version: 0.0.16 kubeVersion: ">= 1.13.0" description: A frontend UI for managing access control to S3 Objects # A chart can be either an 'application' or a 'library' chart. From fb031ad474f8d2bfcc8237cfd58d446bc5b2366c Mon Sep 17 00:00:00 2001 From: Csaky Date: Tue, 21 May 2024 10:38:43 -0700 Subject: [PATCH 5/7] Rate limiting; validate jwt audience in backend auth middleware --- app/app.ts | 9 +++++++++ app/package-lock.json | 21 +++++++++++++++++++++ app/package.json | 1 + app/src/middleware/authentication.ts | 5 ++++- frontend/src/services/interceptors.ts | 2 -- 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/app.ts b/app/app.ts index 8112b1b5..edfee8e3 100644 --- a/app/app.ts +++ b/app/app.ts @@ -7,6 +7,7 @@ import { join } from 'path'; // @ts-expect-error 7016 api-problem lacks a defined interface; code still works fine import Problem from 'api-problem'; import querystring from 'querystring'; +import { rateLimit } from 'express-rate-limit'; import { name as appName, version as appVersion } from './package.json'; import { DEFAULTCORS } from './src/components/constants'; @@ -45,6 +46,14 @@ app.use( }) ); +// rate limiting applied to all routes. +// Current limit: 1000 requests/minute +const limiter = rateLimit({ + windowMs: 60 * 1000, + max: 1000, +}); +app.use(limiter); + // Skip if running tests if (process.env.NODE_ENV !== 'test') { app.use(httpLogger); diff --git a/app/package-lock.json b/app/package-lock.json index 9f057857..b6eb2f8d 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -22,6 +22,7 @@ "config": "^3.3.11", "cors": "^2.8.5", "express": "^4.18.3", + "express-rate-limit": "^7.2.0", "express-winston": "^4.2.0", "helmet": "^7.1.0", "joi": "^17.13.1", @@ -5699,6 +5700,20 @@ "node": ">= 0.10.0" } }, + "node_modules/express-rate-limit": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.2.0.tgz", + "integrity": "sha512-T7nul1t4TNyfZMJ7pKRKkdeVJWa2CqB8NA1P8BwYaoDI5QSBZARv5oMS43J7b7I5P+4asjVXjb7ONuwDKucahg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "4 || 5 || ^5.0.0-beta.1" + } + }, "node_modules/express-winston": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/express-winston/-/express-winston-4.2.0.tgz", @@ -14286,6 +14301,12 @@ "vary": "~1.1.2" } }, + "express-rate-limit": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.2.0.tgz", + "integrity": "sha512-T7nul1t4TNyfZMJ7pKRKkdeVJWa2CqB8NA1P8BwYaoDI5QSBZARv5oMS43J7b7I5P+4asjVXjb7ONuwDKucahg==", + "requires": {} + }, "express-winston": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/express-winston/-/express-winston-4.2.0.tgz", diff --git a/app/package.json b/app/package.json index 2ab3eb52..a85cff50 100644 --- a/app/package.json +++ b/app/package.json @@ -40,6 +40,7 @@ "config": "^3.3.11", "cors": "^2.8.5", "express": "^4.18.3", + "express-rate-limit": "^7.2.0", "express-winston": "^4.2.0", "helmet": "^7.1.0", "joi": "^17.13.1", diff --git a/app/src/middleware/authentication.ts b/app/src/middleware/authentication.ts index e3d5cd29..a4dcd188 100644 --- a/app/src/middleware/authentication.ts +++ b/app/src/middleware/authentication.ts @@ -38,7 +38,10 @@ export const currentUser = async (req: Request, res: Response, next: NextFunctio if (config.has('server.oidc.authority') && config.has('server.oidc.publicKey')) { const publicKey: string = config.get('server.oidc.publicKey'); const pemKey = publicKey.startsWith('-----BEGIN') ? publicKey : _spkiWrapper(publicKey); - isValid = jwt.verify(bearerToken, pemKey, { issuer: config.get('server.oidc.authority') }); + isValid = jwt.verify(bearerToken, pemKey, { + issuer: config.get('server.oidc.authority'), + audience: config.get('frontend.oidc.clientId') + }); } else { throw new Error( 'OIDC environment variables `SERVER_OIDC_AUTHORITY` and `SERVER_OIDC_PUBLICKEY` must be defined' diff --git a/frontend/src/services/interceptors.ts b/frontend/src/services/interceptors.ts index 80207f7a..19e3276f 100644 --- a/frontend/src/services/interceptors.ts +++ b/frontend/src/services/interceptors.ts @@ -12,8 +12,6 @@ import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } fr */ export function appAxios(options: AxiosRequestConfig = {}): AxiosInstance { - console.log(new ConfigService().getConfig().apiPath); - const instance = axios.create({ baseURL: '/' + new ConfigService().getConfig().apiPath, timeout: 10000, From abdb21c8d8d2d38da952f88b90e40dd92826ed6b Mon Sep 17 00:00:00 2001 From: Csaky Date: Tue, 21 May 2024 12:52:17 -0700 Subject: [PATCH 6/7] eslint formatting changes --- charts/bcbox/Chart.yaml | 2 +- frontend/src/components/common/InviteButton.vue | 14 +++++++++----- frontend/src/services/inviteService.ts | 7 +++---- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/charts/bcbox/Chart.yaml b/charts/bcbox/Chart.yaml index 8a314a66..4be78f56 100644 --- a/charts/bcbox/Chart.yaml +++ b/charts/bcbox/Chart.yaml @@ -3,7 +3,7 @@ name: bcbox # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.0.16 +version: 0.0.15 kubeVersion: ">= 1.13.0" description: A frontend UI for managing access control to S3 Objects # A chart can be either an 'application' or a 'library' chart. diff --git a/frontend/src/components/common/InviteButton.vue b/frontend/src/components/common/InviteButton.vue index 6b0fb44a..2a16f8b0 100644 --- a/frontend/src/components/common/InviteButton.vue +++ b/frontend/src/components/common/InviteButton.vue @@ -68,6 +68,7 @@ const inviteLoading: Ref = ref(false); const showInviteLink: Ref = ref(false); const hasManagePermission: Ref = computed(() => { return resourceType.value === 'object' + // eslint-disable-next-line max-len ? permissionStore.isObjectActionAllowed(props.objectId, getUserId.value, Permissions.MANAGE, resource.value?.bucketId) : permissionStore.isBucketActionAllowed(props.bucketId, getUserId.value, Permissions.MANAGE); }); @@ -158,7 +159,6 @@ async function invite() { showInviteLink.value = false; } } catch (error: any) { - console.log('d', error.response); toast.error('Creating Invite', error.response.data.detail, {life: 0}); } inviteLoading.value = false; @@ -219,7 +219,6 @@ async function invite() {

{{ (props.labelText) }} Invite

Make invite available for

@@ -280,13 +279,18 @@ async function invite() { type="email" class="mt-2 max-w-30rem" /> - The Invite will be emailed to this person - + + The Invite will be emailed to this person + +

diff --git a/frontend/src/services/inviteService.ts b/frontend/src/services/inviteService.ts index ebe1bde8..842c3bdf 100644 --- a/frontend/src/services/inviteService.ts +++ b/frontend/src/services/inviteService.ts @@ -1,4 +1,5 @@ import { appAxios, comsAxios } from './interceptors'; +import { invite as inviteEmailTemplate } from '@/utils/emailTemplates'; const PATH = 'permission/invite'; @@ -73,35 +74,19 @@ export default { */ emailInvites(resourceType: string, resource: any, currentUser: any, invites: any){ try { - // build template - let resourceName, subject, body; - // eslint-disable-next-line max-len - const currentUserEmail = `${currentUser.email}`; + let resourceName, subject; // alternate templates depending if resource is a file or a folder if (resourceType === 'object') { resourceName = resource.name; subject = `You have been invited to access ${resourceName} on BCBox`; - body = `

-

${currentUserEmail} invited you to access a file on BCBox

-

Here's a link to access the file that ${currentUserEmail} shared with you:

`; } else if (resourceType === 'bucket') { resourceName = resource.bucketName; subject = `You have been invited to access ${resourceName} on BCBox`; - body = `

-

${currentUserEmail} invited you to access a folder on BCBox

\n -

Here's a link to access the folder that ${currentUserEmail} shared with you:

`; } - // eslint-disable-next-line max-len - body = body + ` - ${resourceName } - -

- - This invite will only work for you and people with existing access. If you do not recognize the sender, do not click on the link above. Only open links that you are expecting from a known sender. -

- Learn more about BCBox - `; + + // build html template for email body + const body = inviteEmailTemplate(resourceType, resourceName, currentUser); // define email data matching the structure required by CHES api const emailData:any = { diff --git a/frontend/src/utils/emailTemplates.ts b/frontend/src/utils/emailTemplates.ts new file mode 100644 index 00000000..f1ad2c80 --- /dev/null +++ b/frontend/src/utils/emailTemplates.ts @@ -0,0 +1,37 @@ +/** + * creates template html for the invite notification + * @param {string} resourceType either 'object' or 'bucket' + * @param {string} resourceName the object name or bucket name + * @param {User | null} currentUser current user sending the invite + * @returns {string} the template html + */ +export function invite(resourceType: string, resourceName: string, currentUser: any): string{ + let html; + // eslint-disable-next-line max-len + const currentUserEmail = `${currentUser.email}`; + // alternate templates depending if resource is a file or a folder + if (resourceType === 'object') { + html = `
+

${currentUserEmail} invited you to access a file on BCBox

+

Here's a link to access the file that ${currentUserEmail} shared with you:

`; + } + else if (resourceType === 'bucket') { + html = `
+

${currentUserEmail} invited you to access a folder on BCBox

\n +

Here's a link to access the folder that ${currentUserEmail} shared with you:

`; + } + // eslint-disable-next-line max-len + html = html + ` + ${resourceName } + +

+ + This invite will only work for you and people with existing access. + If you do not recognize the sender, do not click on the link above.
+ Only open links that you are expecting from a known sender. +


+ Learn more about BCBox + `; + + return html; +}