From 27487fbb55500fce602bc2b83e702d57d721fb15 Mon Sep 17 00:00:00 2001
From: Louis Capitanchik <contact@louiscap.co>
Date: Wed, 17 Feb 2021 17:54:56 +0000
Subject: [PATCH] Implement totp interface, create vault provider for totp

---
 docker-compose.yml             |   2 +-
 package-lock.json              |  76 +-----------
 package.json                   |   8 +-
 src/config/totp.js             |   8 ++
 src/config/vault.js            |  12 ++
 src/http/routes.js             |  43 +++----
 src/services/index.js          |   1 +
 src/services/totp/interface.js |  44 +++++++
 src/services/totp/vault.js     | 209 +++++++++++++++++++++++++++++++++
 9 files changed, 306 insertions(+), 97 deletions(-)
 create mode 100644 src/config/totp.js
 create mode 100644 src/config/vault.js
 create mode 100644 src/services/totp/interface.js
 create mode 100644 src/services/totp/vault.js

diff --git a/docker-compose.yml b/docker-compose.yml
index d0803a1..f324da5 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 5c1733c..d863d46 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 ea44ce1..7dd8c9b 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 0000000..e0d77cd
--- /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 0000000..6a1853f
--- /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 c57794a..db1b7ce 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 e6f7169..8592613 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 0000000..b2ba307
--- /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 0000000..2651d79
--- /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
-- 
GitLab