diff --git a/app/app.ts b/app/app.ts index 3106a9b9..edfee8e3 100644 --- a/app/app.ts +++ b/app/app.ts @@ -7,11 +7,13 @@ 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'; 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'; @@ -44,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); @@ -92,6 +102,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..b6eb2f8d 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", @@ -21,8 +22,11 @@ "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", + "jsonwebtoken": "^9.0.2", "ts-node": "^10.9.2", "winston": "^3.12.0", "winston-transport": "^4.7.0" @@ -893,6 +897,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 +1501,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 +1719,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 +2627,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 +3168,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", @@ -5644,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", @@ -7355,6 +7425,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 +7496,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 +7546,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 +7624,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 +7666,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 +10660,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 +11121,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 +11339,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 +11987,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 +12397,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", @@ -14076,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", @@ -15329,6 +15560,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 +15616,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 +15655,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 +15721,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 +15763,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..a85cff50 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", @@ -39,8 +40,11 @@ "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", + "jsonwebtoken": "^9.0.2", "ts-node": "^10.9.2", "winston": "^3.12.0", "winston-transport": "^4.7.0" 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/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..a4dcd188 --- /dev/null +++ b/app/src/middleware/authentication.ts @@ -0,0 +1,67 @@ +// @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'), + audience: config.get('frontend.oidc.clientId') + }); + } 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/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/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/services/email.ts b/app/src/services/email.ts new file mode 100644 index 00000000..5d0eedc2 --- /dev/null +++ b/app/src/services/email.ts @@ -0,0 +1,85 @@ +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', + e, + status: 500 + }; + } + } + }, +}; + +export default service; 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; +}; 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'; diff --git a/frontend/src/components/common/InviteButton.vue b/frontend/src/components/common/InviteButton.vue index 9710091f..dcd77da2 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 }}

{ {

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..842c3bdf 100644 --- a/frontend/src/services/inviteService.ts +++ b/frontend/src/services/inviteService.ts @@ -1,26 +1,111 @@ -import { comsAxios } from './interceptors'; +import { appAxios, comsAxios } from './interceptors'; +import { invite as inviteEmailTemplate } from '@/utils/emailTemplates'; 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 { + 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`; + } + else if (resourceType === 'bucket') { + resourceName = resource.bucketName; + subject = `You have been invited to access ${resourceName} on 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 = { + 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}`); + + } }, /** 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; +}