From 03468985017c088985c9a010549501bc22447d67 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sun, 8 Aug 2021 07:38:47 -0700 Subject: [PATCH] feat(adapters): Refactored the adapters into a class --- bin/server.js | 2 +- config/env/common.js | 15 ++ package-lock.json | 35 +++- package.json | 3 +- src/adapters/fullstack-jwt.js | 107 ++++++++++++ src/adapters/index.js | 57 ++++-- src/controllers/index.js | 12 +- .../adapters/fullstack-jwt.adapter.unit.js | 162 ++++++++++++++++++ 8 files changed, 360 insertions(+), 33 deletions(-) create mode 100644 src/adapters/fullstack-jwt.js create mode 100644 test/unit/adapters/fullstack-jwt.adapter.unit.js diff --git a/bin/server.js b/bin/server.js index 4fa37fe..dfb8456 100644 --- a/bin/server.js +++ b/bin/server.js @@ -55,7 +55,7 @@ async function startServer () { // Attach REST API and JSON RPC controllers to the app. const Controllers = require('../src/controllers') const controllers = new Controllers() - controllers.attachControllers(app) + await controllers.attachControllers(app) // Enable CORS for testing // THIS IS A SECURITY RISK. COMMENT OUT FOR PRODUCTION diff --git a/config/env/common.js b/config/env/common.js index 7662a5f..271c7c9 100644 --- a/config/env/common.js +++ b/config/env/common.js @@ -31,6 +31,21 @@ module.exports = { ? process.env.EMAILPASS : 'emailpassword', + // FullStack.cash account information, used for automatic JWT handling. + getJwtAtStartup: false, + authServer: process.env.AUTHSERVER + ? process.env.AUTHSERVER + : 'https://auth.fullstack.cash', + apiServer: process.env.APISERVER + ? process.env.APISERVER + : 'https://api.fullstack.cash/v5/', + fullstackLogin: process.env.FULLSTACKLOGIN + ? process.env.FULLSTACKLOGIN + : 'demo@demo.com', + fullstackPassword: process.env.FULLSTACKPASS + ? process.env.FULLSTACKPASS + : 'demo', + // IPFS settings. isCircuitRelay: process.env.ENABLE_CIRCUIT_RELAY ? true : false, diff --git a/package-lock.json b/package-lock.json index eff5d4d..3c7046e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,14 @@ { "name": "ipfs-service-provider", - "version": "1.0.0", + "version": "1.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "1.0.0", + "version": "1.3.0", "license": "MIT", "dependencies": { - "@psf/bch-js": "^4.20.1", + "@psf/bch-js": "^4.20.3", "axios": "^0.21.1", "bcryptjs": "^2.4.3", "glob": "^7.1.6", @@ -16,6 +16,7 @@ "ipfs-coord": "^3.2.0", "jsonrpc-lite": "^2.2.0", "jsonwebtoken": "^8.5.1", + "jwt-bch-lib": "^1.3.0", "kcors": "^2.2.2", "koa": "^2.13.1", "koa-bodyparser": "^4.3.0", @@ -1649,9 +1650,9 @@ "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=" }, "node_modules/@psf/bch-js": { - "version": "4.20.1", - "resolved": "https://registry.npmjs.org/@psf/bch-js/-/bch-js-4.20.1.tgz", - "integrity": "sha512-U/saVHfHA0535w5Z68mHH+8emPJE6LulTcX40kWBjk3Qd3etb9S9j5aqxK0axcwN0s5o2O7AYkfF0NFwPEoa9Q==", + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/@psf/bch-js/-/bch-js-4.20.3.tgz", + "integrity": "sha512-GLuRhTnqVg+cyydEXf+Y2wNOHyJ1+q2/nyt283D9Xq9tQodGBxfT9tydhktWoLVuzlOujdiLxL4KwXDHeaUkhQ==", "dependencies": { "@psf/bip21": "^2.0.1", "@psf/bip32-utils": "^1.0.0", @@ -13544,6 +13545,14 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwt-bch-lib": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jwt-bch-lib/-/jwt-bch-lib-1.3.0.tgz", + "integrity": "sha512-CZs3jUHJlWvConvoTeLDogDEwUysKeojYJxfUhbwJKJrtDCSfi3ZWbsZZ8WN30Xs28aZPDB+qlHPJlTXQmup0w==", + "dependencies": { + "axios": "^0.21.1" + } + }, "node_modules/k-bucket": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/k-bucket/-/k-bucket-5.1.0.tgz", @@ -27603,9 +27612,9 @@ "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=" }, "@psf/bch-js": { - "version": "4.20.1", - "resolved": "https://registry.npmjs.org/@psf/bch-js/-/bch-js-4.20.1.tgz", - "integrity": "sha512-U/saVHfHA0535w5Z68mHH+8emPJE6LulTcX40kWBjk3Qd3etb9S9j5aqxK0axcwN0s5o2O7AYkfF0NFwPEoa9Q==", + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/@psf/bch-js/-/bch-js-4.20.3.tgz", + "integrity": "sha512-GLuRhTnqVg+cyydEXf+Y2wNOHyJ1+q2/nyt283D9Xq9tQodGBxfT9tydhktWoLVuzlOujdiLxL4KwXDHeaUkhQ==", "requires": { "@psf/bip21": "^2.0.1", "@psf/bip32-utils": "^1.0.0", @@ -37767,6 +37776,14 @@ "safe-buffer": "^5.0.1" } }, + "jwt-bch-lib": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jwt-bch-lib/-/jwt-bch-lib-1.3.0.tgz", + "integrity": "sha512-CZs3jUHJlWvConvoTeLDogDEwUysKeojYJxfUhbwJKJrtDCSfi3ZWbsZZ8WN30Xs28aZPDB+qlHPJlTXQmup0w==", + "requires": { + "axios": "^0.21.1" + } + }, "k-bucket": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/k-bucket/-/k-bucket-5.1.0.tgz", diff --git a/package.json b/package.json index 57ed9a4..a8a216b 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "repository": "Permissionless-Software-Foundation/ipfs-service-provider", "dependencies": { - "@psf/bch-js": "^4.20.1", + "@psf/bch-js": "^4.20.3", "axios": "^0.21.1", "bcryptjs": "^2.4.3", "glob": "^7.1.6", @@ -31,6 +31,7 @@ "ipfs-coord": "^3.2.0", "jsonrpc-lite": "^2.2.0", "jsonwebtoken": "^8.5.1", + "jwt-bch-lib": "^1.3.0", "kcors": "^2.2.2", "koa": "^2.13.1", "koa-bodyparser": "^4.3.0", diff --git a/src/adapters/fullstack-jwt.js b/src/adapters/fullstack-jwt.js new file mode 100644 index 0000000..33d7248 --- /dev/null +++ b/src/adapters/fullstack-jwt.js @@ -0,0 +1,107 @@ +/* + A library of utility functions for working with FullStack.cash JWT tokens. + + Feel free to copy this library into your own app, as well as the unit tests + for this file. +*/ + +const JwtLib = require('jwt-bch-lib') +const BCHJS = require('@psf/bch-js') + +class FullStackJWT { + constructor (localConfig = {}) { + // Input Validation + this.authServer = localConfig.authServer + if (!this.authServer || typeof this.authServer !== 'string') { + throw new Error( + 'Must pass a url for the AUTH server when instantiating FullStackJWT class.' + ) + } + this.apiServer = localConfig.apiServer + if (!this.apiServer || typeof this.apiServer !== 'string') { + throw new Error( + 'Must pass a url for the API server when instantiating FullStackJWT class.' + ) + } + this.login = localConfig.fullstackLogin + if (!this.login || typeof this.login !== 'string') { + throw new Error( + 'Must pass a FullStack.cash login (email) instantiating FullStackJWT class.' + ) + } + this.password = localConfig.fullstackPassword + if (!this.password || typeof this.password !== 'string') { + throw new Error( + 'Must pass a FullStack.cash account password when instantiating FullStackJWT class.' + ) + } + + // Encapsulate dependencies + this.jwtLib = new JwtLib({ + // Overwrite default values with the values in the config file. + server: this.authServer, + login: this.login, + password: this.password + }) + + // State + this.apiToken = '' // Default value. + this.bchjs = {} + } + + // Get's a JWT token from FullStack.cash. + async getJWT () { + try { + // Skip connecting FullStack.cash auth server to the network if this is an E2E test. + if (process.env.TEST_TYPE === 'e2e') { + this.apiToken = 'faketoken' + return this.apiToken + } + + // Log into the auth server. + await this.jwtLib.register() + + this.apiToken = this.jwtLib.userData.apiToken + if (!this.apiToken) { + throw new Error('This account does not have a JWT') + } + console.log(`Retrieved JWT token: ${this.apiToken}\n`) + + // Ensure the JWT token is valid to use. + const isValid = await this.jwtLib.validateApiToken() + + // Get a new token with the same API level, if the existing token is not + // valid (probably expired). + if (!isValid.isValid) { + this.apiToken = await this.jwtLib.getApiToken( + this.jwtLib.userData.apiLevel + ) + console.log( + `The JWT token was not valid. Retrieved new JWT token: ${this.apiToken}\n` + ) + } else { + console.log('JWT token is valid.\n') + } + + return this.apiToken + } catch (err) { + console.error( + `Error trying to log into ${this.server} and retrieve JWT token.` + ) + throw err + } + } + + // Create an instance of bchjs with the validated JWT token. Returns this + // instance of bch-js. + instanceBchjs () { + this.bchjs = new BCHJS({ + restURL: this.apiServer, + apiToken: this.apiToken + }) + + return this.bchjs + } +} + +module.exports = FullStackJWT diff --git a/src/adapters/index.js b/src/adapters/index.js index 846e89f..95acde1 100644 --- a/src/adapters/index.js +++ b/src/adapters/index.js @@ -4,29 +4,54 @@ https://troutsblog.com/blog/clean-architecture */ +// Public NPM libraries +const BCHJS = require('@psf/bch-js') + // Load individual adapter libraries. const IPFSAdapter = require('./ipfs') const LocalDB = require('./localdb') const LogsAPI = require('./logapi') const Passport = require('./passport') const Nodemailer = require('./nodemailer') -const { wlogger } = require('./wlogger') +// const { wlogger } = require('./wlogger') const JSONFiles = require('./json-files') +const FullStackJWT = require('./fullstack-jwt') -// Instantiate adapter libraries. -const ipfs = new IPFSAdapter() -const localdb = new LocalDB() -const logapi = new LogsAPI() -const passport = new Passport() -const nodemailer = new Nodemailer() -const jsonFiles = new JSONFiles() +const config = require('../../config') -module.exports = { - ipfs, - localdb, - logapi, - passport, - nodemailer, - wlogger, - jsonFiles +class Adapters { + constructor (localConfig = {}) { + // Encapsulate dependencies + this.ipfs = new IPFSAdapter() + this.localdb = new LocalDB() + this.logapi = new LogsAPI() + this.passport = new Passport() + this.nodemailer = new Nodemailer() + this.jsonFiles = new JSONFiles() + this.bchjs = new BCHJS() + this.config = config + + // Get a valid JWT API key and instance bch-js. + this.fullStackJwt = new FullStackJWT(config) + } + + async start () { + try { + if (this.config.getJwtAtStartup) { + // Get a JWT token and instantiate bch-js with it. Then pass that instance + // to all the rest of the apps controllers and adapters. + await this.fullStackJwt.getJWT() + // Instantiate bch-js with the JWT token, and overwrite the placeholder for bch-js. + this.bchjs = await this.fullStackJwt.instanceBchjs() + } + + // Start the IPFS node. + await this.ipfs.start() + } catch (err) { + console.error('Error in adapters/index.js/start()') + throw err + } + } } + +module.exports = Adapters diff --git a/src/controllers/index.js b/src/controllers/index.js index 69034fe..695c09e 100644 --- a/src/controllers/index.js +++ b/src/controllers/index.js @@ -7,7 +7,7 @@ // Public npm libraries. // Load the Clean Architecture Adapters library -const adapters = require('../adapters') +const Adapters = require('../adapters') // Load the JSON RPC Controller. const JSONRPC = require('./json-rpc') @@ -21,17 +21,17 @@ const RESTControllers = require('./rest-api') class Controllers { constructor (localConfig = {}) { - this.adapters = adapters - this.useCases = new UseCases({ adapters }) + this.adapters = new Adapters() + this.useCases = new UseCases({ adapters: this.adapters }) } async attachControllers (app) { + // Wait for any startup processes to complete for the Adapters libraries. + await this.adapters.start() + // Attach the REST controllers to the Koa app. this.attachRESTControllers(app) - // Start IPFS. - await this.adapters.ipfs.start() - this.attachRPCControllers() } diff --git a/test/unit/adapters/fullstack-jwt.adapter.unit.js b/test/unit/adapters/fullstack-jwt.adapter.unit.js new file mode 100644 index 0000000..c79b34e --- /dev/null +++ b/test/unit/adapters/fullstack-jwt.adapter.unit.js @@ -0,0 +1,162 @@ +/* + Unit tests for the jwt-bch-lib and fullstack-jwt.js adapter library. + +*/ + +const assert = require('chai').assert +const sinon = require('sinon') + +const FullStackJWT = require('../../../src/adapters/fullstack-jwt') + +describe('#FullStackJWT', () => { + let sandbox + let uut + + beforeEach(() => { + sandbox = sinon.createSandbox() + + const localConfig = { + authServer: 'someserver', + apiServer: 'someserver', + login: 'somelogin', + password: 'somepassword' + } + uut = new FullStackJWT(localConfig) + }) + afterEach(() => sandbox.restore()) + + describe('#constructor', () => { + it('should throw an error if auth server is not specified', () => { + try { + uut = new FullStackJWT() + + assert.fail('Unexpected code path') + console.log(uut) // For linting. + } catch (err) { + assert.include( + err.message, + 'Must pass a url for the AUTH server when instantiating FullStackJWT class.' + ) + } + }) + + it('should throw an error if api server is not specified', () => { + try { + const localConfig = { + authServer: 'someserver' + } + + uut = new FullStackJWT(localConfig) + + assert.fail('Unexpected code path') + console.log(uut) // For linting. + } catch (err) { + assert.include( + err.message, + 'Must pass a url for the API server when instantiating FullStackJWT class.' + ) + } + }) + + it('should throw an error if login is not specified', () => { + try { + const localConfig = { + authServer: 'someserver', + apiServer: 'someserver' + } + uut = new FullStackJWT(localConfig) + + assert.fail('Unexpected code path') + console.log(uut) // For linting. + } catch (err) { + assert.include( + err.message, + 'Must pass a FullStack.cash login (email) instantiating FullStackJWT class.' + ) + } + }) + + it('should throw an error if login is not specified', () => { + try { + const localConfig = { + authServer: 'someserver', + apiServer: 'someserver', + login: 'somelogin' + } + uut = new FullStackJWT(localConfig) + + assert.fail('Unexpected code path') + console.log(uut) // For linting. + } catch (err) { + assert.include( + err.message, + 'Must pass a FullStack.cash account password when instantiating FullStackJWT class.' + ) + } + }) + }) + + describe('#getJWT', () => { + it('should return the JWT token', async () => { + // Mock dependencies to force a code path. + sandbox.stub(uut.jwtLib, 'register').resolves({}) + uut.jwtLib.userData.apiToken = 'abc123' + sandbox.stub(uut.jwtLib, 'validateApiToken').resolves({ isValid: true }) + + const result = await uut.getJWT() + // console.log('result: ', result) + + assert.equal(result, 'abc123') + }) + + it('should catch and throw an error', async () => { + try { + // Force an error + sandbox.stub(uut.jwtLib, 'register').rejects(new Error('test error')) + + await uut.getJWT() + + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + + it('should throw an error if user does not have a JWT', async () => { + try { + // Mock dependencies to force a code path. + sandbox.stub(uut.jwtLib, 'register').resolves({}) + + await uut.getJWT() + + assert.fail('Unexpected code path') + } catch (err) { + // console.log('err.message: ', err.message) + assert.include(err.message, 'This account does not have a JWT') + } + }) + + it('should retrieve a new JWT token if the old one invalid', async () => { + // Mock dependencies to force a code path. + sandbox.stub(uut.jwtLib, 'register').resolves({}) + uut.jwtLib.userData.apiToken = 'abc123' + uut.jwtLib.userData.apiLevel = 30 + sandbox.stub(uut.jwtLib, 'validateApiToken').resolves({ isValid: false }) + sandbox.stub(uut.jwtLib, 'getApiToken').resolves('xyz789') + + const result = await uut.getJWT() + // console.log('result: ', result) + + assert.equal(result, 'xyz789') + }) + }) + + describe('#instanceBchjs', () => { + it('should return an instance of bch-js', () => { + const result = uut.instanceBchjs() + // console.log('result: ', result) + + assert.property(result, 'restURL') + }) + }) +})