diff --git a/.gitignore b/.gitignore index 3c3629e..0bd31d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ node_modules +cfg/key.pem +cfg/cert.pem diff --git a/README.md b/README.md index e69de29..55f218c 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,45 @@ +# Installation + + npm install + +# Generate keys + +From within the config/ directory: + + openssl genrsa -out key.pem + openssl req -new -key key.pem -out csr.pem + openssl x509 -req -days 9999 -in csr.pem -signkey key.pem -out cert.pem + rm csr.pem + +# Configuration + + Ensure the config file in config/ matches your details. + +# Running the server + + node bin/server.js config/prod.json + +# Endpoints + +## GET /static/* + +This is where all the front-end code goes. All files will be served as-is as +found in that directory. The default is static/index.html. There is no +authentication; all files are public. + +## GET /login + +Call this endpoint to begin the login cycle. It will redirect you to the SSO +login page. + +## GET /token + +Upon successful login, the SSO login page will redirect to this endpoint. This +endpoint will return a 204, along with a X-Auth-Token header that must be saved +by the front-end code. All subsequent calls should provide this X-Auth-Token +header. + +## Other + +All other calls will be passed through to cloudapi. For these calls to succeed, +they MUST provide the X-Auth-Token header that the /token endpoint returns. diff --git a/bin/server.js b/bin/server.js index fadd3aa..c0ce6d3 100644 --- a/bin/server.js +++ b/bin/server.js @@ -1,25 +1,26 @@ +'use strict'; +// Copyright 2021 Spearhead Systems S.R.L. + const mod_restify = require('restify'); const mod_cueball = require('cueball'); +const mod_crypto = require('crypto'); +const mod_fs = require('fs'); +const mod_sdcauth = require('smartdc-auth'); -const cloudapi_client = mod_restify.createStringClient({ - url: 'https://eu-ro-1.api.spearhead.cloud', - agent: new mod_cueball.HttpsAgent({ - spares: 4, maximum: 10, - recovery: { - default: { - timeout: 2000, - retries: 5, - delay: 250, - maxDelay: 1000 - } - } - }) -}); - +// Globals that are assigned to by main(). They are used by proxy() and login(). +let CONFIG = {}; +let PRIVATE_KEY = ''; +let CLOUDAPI = {}; +let SIGNER = {}; +// Take any HTTP request that has a token, sign that request with an +// HTTP-Signature header, and pass it along to cloudapi. Return any response +// from cloudapi to our client caller. Effectively this function is a proxy +// that solely signs the request as it passes through. function proxy(req, res, cb) { + // return data from cloudapi to the client caller function proxyReturn(err, _, res2, data) { if (err && !res2.statusCode) { res.send(500); @@ -34,38 +35,137 @@ function proxy(req, res, cb) { return cb(); } - const opts = { - path: req.url, - headers: req.headers - }; - - switch (req.method) { - case 'GET': cloudapi_client.get(opts, proxyReturn); break; - case 'DEL': cloudapi_client.del(opts, proxyReturn); break; - case 'HEAD': cloudapi_client.head(opts, proxyReturn); break; - case 'POST': cloudapi_client.post(opts, req.body, proxyReturn); break; - case 'PUT': cloudapi_client.del(opts, req.body, proxyReturn); break; + // check the X-Auth-Token is present + if (req.header('X-Auth-Token') == undefined) { + res.send({"Error": "X-Auth-Token header missing"}); + res.send(401); + return cb(); } + + // sign the request before forwarding to cloudapi + let headers = req.headers; + var rs = mod_sdcauth.requestSigner({ sign: SIGNER }); + headers.date = rs.writeDateHeader(); + rs.writeTarget(req.method, req.url); + + rs.sign(function signedCb(err, authz) { + if (err) { + return (cb(err)); + } + + headers.authorization = authz; + + const opts = { + path: req.url, + headers: headers + }; + + // make the call to cloudapi + switch (req.method) { + case 'GET': CLOUDAPI.get(opts, proxyReturn); break; + case 'DEL': CLOUDAPI.del(opts, proxyReturn); break; + case 'HEAD': CLOUDAPI.head(opts, proxyReturn); break; + case 'POST': CLOUDAPI.post(opts, req.body, proxyReturn); break; + case 'PUT': CLOUDAPI.del(opts, req.body, proxyReturn); break; + } + }); } +// Redirect to SSO with (signed) details that the SSO will need to generate a +// secure token. Once the user successfully logs in, the token is returned +// through an SSO redirect to token() below. function login(req, res, cb) { - res.send('login'); + const query = { + permissions: '{"cloudapi":["/my/*"]}', + returnto: CONFIG.urls.local + '/token', + now: new Date().toUTCString(), + keyid: '/' + CONFIG.key.user + '/keys/' + CONFIG.key.id, + nonce: mod_crypto.randomBytes(15).toString('base64') + }; + + // the query args MUST be sorted for SSO to validate + const querystr = Object.keys(query).sort().map(function encode(key) { + return key + '=' + encodeURIComponent(query[key]); + }).join('&'); + + let url = CONFIG.urls.sso + '/login?' + querystr; + + const signer = mod_crypto.createSign('sha256'); + signer.update(encodeURIComponent(url)); + const signature = signer.sign(PRIVATE_KEY, 'base64'); + url += '&sig=' + encodeURIComponent(signature); + + res.redirect(url, cb); +} + + +// Once a user successfully logs in, they are redirected to here. We convert +// the token that was returned to use as query arg into an X-Auth-Token header +// that is returned to the client caller. This header must be provided by the +// client from now on in order to communicate with Cloudapi. +function token(req, res, cb) { + const token = decodeURIComponent(req.query().split('=')[1]); + res.header('X-Auth-Token', token); + res.send(204); return cb(); } +// Start up HTTP server and pool of cloudapi clients. +// +// Read from config file, establish crypto singer needed for requests to +// cloudapi, prepare pool of HTTP clients for communication to cloudapi, and +// start up HTTP server. function main() { - const server = mod_restify.createServer(); + // load config and private key + const configStr = mod_fs.readFileSync(process.argv[2]); + CONFIG = JSON.parse(configStr); + PRIVATE_KEY = mod_fs.readFileSync(CONFIG.key.path); + + // signer is used for signing requests made to cloudapi with HTTP-Signature + SIGNER = mod_sdcauth.privateKeySigner({ + key: PRIVATE_KEY, + keyId: CONFIG.key.id, + user: CONFIG.key.user + }); + + // enable pool of clients to cloudapi + CLOUDAPI = mod_restify.createStringClient({ + url: CONFIG.urls.cloudapi, + agent: new mod_cueball.HttpsAgent({ + spares: 4, + maximum: 10, + recovery: { + default: { + timeout: 2000, + retries: 5, + delay: 250, + maxDelay: 1000 + } + } + }) + }); + + // prepare HTTP server + const options = { + key: mod_fs.readFileSync(CONFIG.server.key), + cert: mod_fs.readFileSync(CONFIG.server.cert) + }; + + const server = mod_restify.createServer(options); server.use(mod_restify.authorizationParser()); server.use(mod_restify.bodyParser()); + // where to server static content from server.get(/^\/static.*/, mod_restify.plugins.serveStatic({ directory: 'static', default: 'index.html' })); - server.post('/login', login); + // route HTTP requests to proper functions + server.get('/login', login); + server.get('/token', token); server.get(/^/, proxy); server.put(/^/, proxy); @@ -73,7 +173,8 @@ function main() { server.post(/^/, proxy); server.head(/^/, proxy); - server.listen(8080, function () { + // enable HTTP server + server.listen(CONFIG.server.port, function listening() { console.log('%s listening at %s', server.name, server.url); }); } diff --git a/cfg/example.json b/cfg/example.json new file mode 100644 index 0000000..5fa24b2 --- /dev/null +++ b/cfg/example.json @@ -0,0 +1,19 @@ +{ + "server": { + "//port": "if using a non-default port, make sure urls.local matches", + "port": 443, + "key": "cfg/key.pem", + "cert": "cfg/cert.pem" + }, + "urls": { + "local": "https://localhost", + "sso": "https://sso.spearhead.cloud", + "cloudapi": "https://eu-ro-1.api.spearhead.cloud" + }, + "key": { + "user": "developer_name", + "//id": "replace id below with the signature of the path'd key", + "id": "0b:bf:00:e7:95:7b:e8:13:54:7f:37:00:04:07:64:04", + "path": "/path/to/developer_key" + } +} diff --git a/package-lock.json b/package-lock.json index a1b4322..4cd4f49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,6 +102,11 @@ "safe-json-stringify": "1.2.0" } }, + "clone": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/clone/-/clone-0.1.5.tgz", + "integrity": "sha1-RvKRQ9B2bWY9vX+At1IKFXg9IEI=" + }, "cmdutil": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/cmdutil/-/cmdutil-1.1.0.tgz", @@ -538,6 +543,27 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==" }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "1.0.2", + "inherits": "2.0.4", + "isarray": "1.0.0", + "process-nextick-args": "2.0.1", + "safe-buffer": "5.1.2", + "string_decoder": "1.1.1", + "util-deprecate": "1.0.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, "restify": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/restify/-/restify-4.3.4.tgz", @@ -748,6 +774,102 @@ "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", "integrity": "sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=" }, + "smartdc-auth": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/smartdc-auth/-/smartdc-auth-2.5.7.tgz", + "integrity": "sha1-QtRXEOeR3rkt+RMmyO7RvVqELLY=", + "requires": { + "assert-plus": "1.0.0", + "bunyan": "1.8.12", + "clone": "0.1.5", + "dashdash": "1.10.1", + "http-signature": "1.3.5", + "once": "1.3.0", + "sshpk": "1.16.1", + "sshpk-agent": "1.8.1", + "vasync": "1.4.3" + }, + "dependencies": { + "bunyan": { + "version": "1.8.12", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.12.tgz", + "integrity": "sha1-8VDw9nSKvdcq6uhPBEA74u8RN5c=", + "requires": { + "dtrace-provider": "0.8.8", + "moment": "2.29.1", + "mv": "2.1.1", + "safe-json-stringify": "1.2.0" + } + }, + "dashdash": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.10.1.tgz", + "integrity": "sha1-Cr8a+JqPUSmoHxjCs1sh3yJiL2A=", + "requires": { + "assert-plus": "0.1.5" + }, + "dependencies": { + "assert-plus": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz", + "integrity": "sha1-7nQAlBMALYTOxyGcasgRgS5yMWA=" + } + } + }, + "extsprintf": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.0.tgz", + "integrity": "sha1-TVi4Fazlvr/E6/A8+YsKdgSpm4Y=" + }, + "json-schema": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.2.tgz", + "integrity": "sha1-UDVPGfYDkXxpX3C4Wvp3w7DyNQY=" + }, + "jsprim": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-0.3.0.tgz", + "integrity": "sha1-zRNGbqJIDb2DlqVw1H0x3aR2+LE=", + "requires": { + "extsprintf": "1.0.0", + "json-schema": "0.2.2", + "verror": "1.3.3" + }, + "dependencies": { + "verror": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.3.3.tgz", + "integrity": "sha1-impKw6jHdLb2h/7OSb3/14VS4s0=", + "requires": { + "extsprintf": "1.0.0" + } + } + } + }, + "once": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.0.tgz", + "integrity": "sha1-FRr4a/wfCMS58H0GqyUP/L61ZYE=" + }, + "vasync": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-1.4.3.tgz", + "integrity": "sha1-yG1S4rcWE9Ke7fFZ8xNdvnSc7jc=", + "requires": { + "jsprim": "0.3.0", + "verror": "1.1.0" + } + }, + "verror": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.1.0.tgz", + "integrity": "sha1-KktOsUogcFHnWm+U7lExW/FzobA=", + "requires": { + "extsprintf": "1.0.0" + } + } + } + }, "sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", @@ -764,6 +886,20 @@ "tweetnacl": "0.14.5" } }, + "sshpk-agent": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sshpk-agent/-/sshpk-agent-1.8.1.tgz", + "integrity": "sha512-YzAzemVrXEf1OeZUpveXLeYUT5VVw/I5gxLeyzq1aMS3pRvFvCeaGliNFjKR3VKtGXRqF9WamqKwYadIG6vStQ==", + "requires": { + "assert-plus": "1.0.0", + "dashdash": "1.14.1", + "getpass": "0.1.7", + "mooremachine": "2.3.0", + "readable-stream": "2.3.7", + "sshpk": "1.16.1", + "verror": "1.10.0" + } + }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", diff --git a/package.json b/package.json index be4b32b..7075c70 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "cueball": "2.10.1", "http-signature": "1.3.5", "restify": "4.3.4", - "vasync": "2.2.0" + "vasync": "2.2.0", + "smartdc-auth": "2.5.7" } }