diff --git a/docker-compose.yml b/docker-compose.yml index d0803a1e825e86cdd4f157e728eb0f988f180af9..f324da52b96cf6386f0f58c521654857bab8321c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,4 +28,4 @@ services: HASURA_GRAPHQL_ENABLE_CONSOLE: "true" HASURA_GRAPHQL_DEV_MODE: "true" HASURA_GRAPHQL_ADMIN_SECRET: "secret_key" - HASURA_GRAPHQL_JWT_SECRET: '{"jwk_url":"http://localhost:7123/api/.secure/jwks","claims_map":{"x-hasura-user-id":{"path":"$$.session.id"},"x-hasura-allowed-roles":{"path":"$$.session.roles"},"x-hasura-default-role":{"path":"$$.session.roles[0]"}}}' \ No newline at end of file + HASURA_GRAPHQL_JWT_SECRET: '{"jwk_url":"http://localhost:7123/.well-known/jwks.json","claims_map":{"x-hasura-user-id":{"path":"$$.session.id"},"x-hasura-allowed-roles":{"path":"$$.session.roles"},"x-hasura-default-role":{"path":"$$.session.roles[0]"}}}' \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5c1733c9e14ca065886b23ae29bbfbcb7d3f9ee4..d863d4643a20fa314c26d1652e8751b34bd74ce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3328,11 +3328,6 @@ "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz", "integrity": "sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig==" }, - "fast-xml-parser": { - "version": "3.17.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.17.4.tgz", - "integrity": "sha512-qudnQuyYBgnvzf5Lj/yxMcf4L9NcVWihXJg7CiU1L+oUCq8MUnFEfH2/nXR/W5uq+yvUN1h7z6s7vs2v1WkL1A==" - }, "fb-watchman": { "version": "2.0.1", "resolved": "https://npm.lcr.gr/fb-watchman/-/fb-watchman-2.0.1.tgz", @@ -6545,11 +6540,6 @@ "picomatch": "^2.0.5" } }, - "mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" - }, "mime-db": { "version": "1.44.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", @@ -6752,7 +6742,7 @@ }, "node-fetch": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "resolved": "https://npm.lcr.gr/node-fetch/-/node-fetch-2.6.1.tgz", "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" }, "node-forge": { @@ -7845,6 +7835,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.3.tgz", "integrity": "sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==", + "dev": true, "requires": { "lodash": "^4.17.15" } @@ -7853,6 +7844,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.8.tgz", "integrity": "sha512-dapwLGqkHtwL5AEbfenuzjTYg35Jd6KPytsC2/TLkVMz8rm+tNt72MGUWT1RP/aYawMpN6HqbNGBQaRcBtjQMQ==", + "dev": true, "requires": { "request-promise-core": "1.1.3", "stealthy-require": "^1.1.1", @@ -8668,7 +8660,8 @@ "stealthy-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=" + "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", + "dev": true }, "stream-events": { "version": "1.0.5", @@ -9102,65 +9095,6 @@ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, - "twit": { - "version": "2.2.11", - "resolved": "https://registry.npmjs.org/twit/-/twit-2.2.11.tgz", - "integrity": "sha512-BkdwvZGRVoUTcEBp0zuocuqfih4LB+kEFUWkWJOVBg6pAE9Ebv9vmsYTTrfXleZGf45Bj5H3A1/O9YhF2uSYNg==", - "requires": { - "bluebird": "^3.1.5", - "mime": "^1.3.4", - "request": "^2.68.0" - } - }, - "twittersignin": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/twittersignin/-/twittersignin-1.2.0.tgz", - "integrity": "sha512-65Lhs4XOcM/ZwPfQUl/tOmRaO/jP7F8M7NAE2IKzheKxVTWV8nYHQsRwwqf2HfNI2MkSBSVJVzxiXe/vMiq51w==", - "requires": { - "fast-xml-parser": "^3.14.0", - "request": "^2.88.2", - "twit": "2.2.11" - }, - "dependencies": { - "qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" - }, - "request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "requires": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - } - }, - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" - } - } - }, "type": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", diff --git a/package.json b/package.json index ea44ce1bbefa4bd76e3fdc2b950fe966c8220d2a..7dd8c9be4650d8d89b58b88411883b172061e868 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,12 @@ "watch": "NODE_PATH=src DEBUG=server:* nodemon server --ignore './client/src' --ignore './certs' --ignore 'google-storage.json'", "watch:queue": "NODE_PATH=src QUEUE_ACTION=consumer DEBUG=server:* nodemon worker --ignore './client/src' --ignore './certs' --ignore 'google-storage.json'", "exec:env": "docker-compose -p jetenv up", - "exec:ngrok": "ngrok http 7123 --hostname trash.4l2.uk", + "exec:ngrok": "ngrok http 7123 --hostname trash.4l2.uk", "test": "NODE_ENV=testing NODE_PATH=src node scripts/jest.js", "start": "NODE_PATH=src node server", "cmd": "NODE_PATH=src node run", "sql": "NODE_PATH=src node scripts/npx-boot.js sequelize", - "repl": "NODE_PATH=src node -e 'Object.entries(require(\"bootstrap\")).forEach(([key, value]) => Object.defineProperty(global, key, { value }))' -i" + "repl": "NODE_PATH=src node -e 'Object.entries(require(\"bootstrap\")).forEach(([key, value]) => Object.defineProperty(global, key, { value })); boot().then(() => console.log(\"Booted\"))' -i" }, "author": "Louis Capitanchik <louis@microhacks.co.uk>", "license": "GPL-3.0+", @@ -49,6 +49,7 @@ "moment": "^2.27.0", "moment-range": "^4.0.2", "multer": "^1.4.2", + "node-fetch": "^2.6.1", "nodemailer": "^6.4.17", "oauth2-server": "^3.1.1", "pg": "^8.3.0", @@ -56,12 +57,9 @@ "pluralize": "^8.0.0", "redbird": "^0.10.0", "remarkable": "^2.0.1", - "request": "^2.88.2", - "request-promise-native": "^1.0.8", "scrypt-kdf": "^2.0.1", "sequelize": "^6.3.3", "sequelize-cli": "^6.2.0", - "twittersignin": "^1.2.0", "uuid": "^8.3.1", "yargs": "^13.3.2" }, diff --git a/src/config/totp.js b/src/config/totp.js new file mode 100644 index 0000000000000000000000000000000000000000..e0d77cdc0e010fd9373f178af3001d690cb1fc0a --- /dev/null +++ b/src/config/totp.js @@ -0,0 +1,8 @@ +const { env } = require('bootstrap') + +module.exports = { + driver: env('TOTP_DRIVER', 'vault'), + vault: { + + } +} \ No newline at end of file diff --git a/src/config/vault.js b/src/config/vault.js new file mode 100644 index 0000000000000000000000000000000000000000..6a1853fd7cb3f8ddfe8e6a6f748eb8d5f8e62567 --- /dev/null +++ b/src/config/vault.js @@ -0,0 +1,12 @@ +const { env } = require('bootstrap') + +module.exports = { + base_url: env('VAULT_BASE_URL'), + totp_path: env('VAULT_TOTP_PATH', '/v1/totp'), + kv_path: env('VAULT_KV_PATH', '/v1/kv/data'), + auth_path: env('VAULT_AUTH_PATH'), + credentials: { + role_id: env('VAULT_ROLE_ID'), + secret_id: env('VAULT_ROLE_SECRET'), + }, +} diff --git a/src/http/routes.js b/src/http/routes.js index c57794a95edd4b2c8a6b84ee235954de96f5d076..db1b7ceef2f3edf3cfeea732e6f57f87fb50b424 100644 --- a/src/http/routes.js +++ b/src/http/routes.js @@ -15,8 +15,31 @@ const profiling = require('http/middleware/Profiler') const loaders = require('http/middleware/MountLoaders') const userGate = require('http/middleware/RequiresAuth') +const well_known = new Router({ prefix: '/.well-known' }) +well_known.get('wk.jwks', '/jwks.json', async ctx => { + const { getKeys } = require('core/utils/jwt') + const { pub } = getKeys() + const { default: fromKeyLike } = require('jose/jwk/from_key_like') + + const jwk = await fromKeyLike(pub) + + ctx.set('Cache-Control', `public, max-age=30`) + + ctx.body = { + keys: [{ + use: 'sig', + ...jwk, + alg: 'RS256', + }], + } +}) + const web = new Router() web.use(profiling) + +web.use(well_known.allowedMethods()) +web.use(well_known.routes()) + web.get('/login', ctx => { const data = {} if (ctx.query.login_state) { @@ -45,24 +68,6 @@ web.post('/auth/token', AuthServer.token) debug(`Mounted PUT ${ p } to upload local files`) }()) -const security = new Router({ prefix: '/.secure' }) -security.get('/jwks', async ctx => { - const { getKeys } = require('core/utils/jwt') - const { pub } = getKeys() - const { default: fromKeyLike } = require('jose/jwk/from_key_like') - - const jwk = await fromKeyLike(pub) - - ctx.set('Cache-Control', `public, max-age=30`) - - ctx.body = { - keys: [{ - use: 'sig', - ...jwk, - alg: 'RS256', - }], - } -}) const apiRouter = new Router({ prefix: '/api' }) const apiLegacy = new Router({ prefix: '/api/api' }) @@ -82,8 +87,6 @@ function mount(api) { } }) - api.use(security.allowedMethods()) - api.use(security.routes()) api.post('/metrics', controller('api/content', 'postMetric')) api.get('/metrics', controller('api/content', 'getWithin')) diff --git a/src/services/index.js b/src/services/index.js index e6f7169d5dc7a1ab791cc19495d02930bb924514..8592613c6a50ff0ce4fbe4eb3b7184dd84634a64 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -5,6 +5,7 @@ const SERVICES = [ ['mail', 'MAIL_DRIVER', 'log'], ['queue', 'QUEUE_DRIVER', 'async'], ['fs', 'FS_DRIVER', 'local'], + ['totp', 'TOTP_DRIVER', 'vault'], ] const services = {} diff --git a/src/services/totp/interface.js b/src/services/totp/interface.js new file mode 100644 index 0000000000000000000000000000000000000000..b2ba3072b6bf917196a81b3543c27ba4d3e6899b --- /dev/null +++ b/src/services/totp/interface.js @@ -0,0 +1,44 @@ +const { notImplemented } = require('services/utils') + +/** + * @typedef {Object} TOTPConfig + * @property {string} secret The secret that can be manually added to an OTP app to link an account + * @property {string} barcode A base64 encoded barcode that can be rendered as an image and scanned with a barcode reader + * @property {string} url A URI containing the secret that can be encoded into a barcode and scanned with a barcode reader + */ + +module.exports = class TotpProvider { + /** + * @param {Object} user + * @return {TOTPConfig} + */ + generate(user) { + notImplemented('TotpProvider', 'generate') + } + + /** + * @param {string} userId + * @param {string} code + * @return {boolean} + */ + verify(userid, code) { + notImplemented('TotpProvider', 'verify') + } + + /** + * @param {string} userid + * @return {Array<string>} + */ + createRecoveryCodes(userid) { + notImplemented('TotpProvider', 'createRecoveryCodes') + } + + /** + * @param {string} userid + * @param {string} code + * @return {boolean} + */ + verifyRecoveryCode(userid, code) { + notImplemented('TotpProvider', 'verifyRecoveryCode') + } +} diff --git a/src/services/totp/vault.js b/src/services/totp/vault.js new file mode 100644 index 0000000000000000000000000000000000000000..2651d792967de96d872e97ba588e9d96191c5f03 --- /dev/null +++ b/src/services/totp/vault.js @@ -0,0 +1,209 @@ +const TotpProvider = require('./interface') +const { config } = require('bootstrap') +const fetch = require('node-fetch') +const { URL } = require('url') +const pathUtil = require('path') +const moment = require('moment') +const threadContext = require('core/injection/ThreadContext') + +class VaultTotpProvider extends TotpProvider { + constructor() { + super() + this.config = config('totp') + this.baseUrl = config('vault.base_url') + this.authPath = config('vault.auth_path') + this.totpPath = config('vault.totp_path') + this.kvPath = config('vault.kv_path') + this.credentials = config('vault.credentials') + + this.token = null + } + + createUrl(...path) { + const fullPath = pathUtil.join(...path) + return (new URL(fullPath, this.baseUrl)).toString() + } + + async refreshToken() { + return threadContext.profile('totp.refreshToken', undefined, async () => { + const path = this.createUrl(this.authPath) + const result = await fetch(path, { + method: 'post', + body: JSON.stringify(this.credentials), + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (result.ok) { + const payload = await result.json() + const token = payload?.auth?.client_token + + if (token == null) { + this.token = null + } else { + const expires = moment.utc().add(payload?.auth?.lease_duration ?? 5 * 60, 'seconds') + this.token = { + token, + expires, + } + } + } else { + this.token = null + } + }) + } + + async preflight() { + return threadContext.profile('totp.preflight', undefined, async () => { + if (this.token == null) { + await this.refreshToken() + } else if (this.token.expires.clone().subtract(30, 'seconds').isSameOrBefore(moment.utc())) { // Less than 30 seconds from expiry + await this.refreshToken() + } + + if (this.token == null) { + throw new TypeError('Unable to get vault lease') + } + + return this.token + }) + } + + async generate(user) { + if (user == null) { + throw new TypeError('Must provide a user to create a TOTP key') + } + return threadContext.profile('totp.generate', user?.id, async () => { + const id = user.id + const email = user.email + + const payload = { + account_name: email, + issuer: 'Jetsam', + generate: true, + } + const path = this.createUrl(this.totpPath, 'keys', id) + const { token } = await this.preflight() + + const response = await fetch(path, { + method: 'post', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + 'X-Vault-Token': token, + }, + }) + + if (response.ok) { + const { data } = await response.json() + const barcode = data?.barcode + const url = data?.url + + const parsed = new URL(url) + const secret = parsed.searchParams.get('secret') + + return { + barcode, + url, + secret, + } + } + + return null + }) + } + + async verify(id, code) { + if (id == null) { + throw new TypeError('Must provide the User ID to verify against') + } + if (code == null) { + throw new TypeError('Must provide a TOTP code to verify') + } + + const path = this.createUrl(this.totpPath, 'code', id) + const payload = { code } + const { token } = await this.preflight() + + const response = await fetch(path, { + method: 'post', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + 'X-Vault-Token': token, + }, + }) + + if (response.ok) { + const { data } = await response.json() + return data?.valid ?? false + } + + return false + } + + async _saveRecoveryCodes(id, codes) { + const path = this.createUrl(this.kvPath, 'totp_recovery', id) + const payload = { data: { codes } } + const { token } = await this.preflight() + + return await fetch(path, { + method: 'post', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + 'X-Vault-Token': token, + }, + }) + } + + async createRecoveryCodes(userid) { + await this.preflight() + const crypto = require('core/utils/crypto') + let codes = [null, null, null, null, null] + codes = await Promise.all(codes.map(c => crypto.secureHexString(16))) + const hashes = await Promise.all(codes.map(c => crypto.hash(c))) + + const response = await this._saveRecoveryCodes(userid, hashes) + if (response.ok) { + return { codes } + } + return null + } + + async verifyRecoveryCode(userid, code) { + const crypto = require('core/utils/crypto') + const { token } = await this.preflight() + const path = this.createUrl(this.kvPath, 'totp_recovery', userid) + + const response = await fetch(path, { + headers: { + 'X-Vault-Token': token, + }, + }) + + if (response.ok) { + const { data } = await response.json() + const { codes } = data.data + + let found = null + search: for (const hash of codes) { + if (await crypto.verify(hash, code)) { + found = hash + break search + } + } + + if (found != null) { + const newCodes = codes.filter(c => c !== found) + await this._saveRecoveryCodes(userid, newCodes) + return true + } + } + + return false + } +} + +module.exports = new VaultTotpProvider() \ No newline at end of file