diff --git a/lib/extensions/admin/account.html b/lib/extensions/admin/account.html index 94f5f3d..33a7aa2 100644 --- a/lib/extensions/admin/account.html +++ b/lib/extensions/admin/account.html @@ -3,8 +3,9 @@ Telebit Account +

Login

- +
@@ -19,37 +20,44 @@ }); var $ = function () { return document.querySelector.apply(document, arguments); } - function onChangeProvider(providerUri) { - // example https://oauth3.org - return oauth3.setIdentityProvider(providerUri); - } + function onChangeProvider(providerUri) { + // example https://oauth3.org + return oauth3.setIdentityProvider(providerUri); + } - // This opens up the login window for the specified provider - // - function onClickLogin(ev) { + // This opens up the login window for the specified provider + // + function onClickLogin(ev) { ev.preventDefault(); ev.stopPropagation(); + var email = $('.js-auth-subject').value; + // TODO check subject for provider viability return oauth3.authenticate({ - subject: $('.js-auth-subject').value + subject: email + , scope: 'email@oauth3.org' }).then(function (session) { - console.info('Authentication was Successful:'); - console.log(session); + console.info('Authentication was Successful:'); + console.log(session); - // You can use the PPID (or preferably a hash of it) as the login for your app - // (it securely functions as both username and password which is known only by your app) - // If you use a hash of it as an ID, you can also use the PPID itself as a decryption key - // - console.info('Secure PPID (aka subject):', session.token.sub); + // You can use the PPID (or preferably a hash of it) as the login for your app + // (it securely functions as both username and password which is known only by your app) + // If you use a hash of it as an ID, you can also use the PPID itself as a decryption key + // + console.info('Secure PPID (aka subject):', session.token.sub); - return oauth3.request({ - url: 'https://api.oauth3.org/api/issuer@oauth3.org/jwks/:sub/:kid.json' + function listStuff() { + window.alert("TODO: show authorized devices, domains, and connectivity information"); + } + + return oauth3.request({ + url: 'https://api.oauth3.org/api/issuer@oauth3.org/jwks/:sub/:kid.json' .replace(/:sub/g, session.token.sub) .replace(/:kid/g, session.token.iss) - , session: session - }).then(function (resp) { + , session: session + }).then(function (resp) { console.info("Public Key:"); console.log(resp.data); @@ -62,25 +70,44 @@ console.log(resp.data); return oauth3.request({ - url: 'https://api.telebit.cloud/api/telebit.cloud/account' + url: 'https://api.' + location.hostname + '/api/telebit.cloud/account' , session: session }).then(function (resp) { console.info("Telebit Account:"); console.log(resp.data); + if (1 === resp.data.accounts.length) { + listStuff(resp); + } else if (0 === resp.data.accounts.length) { + return oauth3.request({ + url: 'https://api.' + location.hostname + 'api/telebit.cloud/account' + , method: 'POST' + , session: session + , body: { + email: email + } + }).then(function (resp) { + listStuff(resp); + }); + } if (resp.data.accounts.length > 2) { + window.alert("Multiple accounts."); + } else { + window.alert("Bad response."); + } + }); }); - }); + }); - }, function (err) { - console.error('Authentication Failed:'); - console.log(err); - }); - } + }, function (err) { + console.error('Authentication Failed:'); + console.log(err); + }); + } $('body form.js-auth-form').addEventListener('submit', onClickLogin); onChangeProvider('oauth3.org'); diff --git a/lib/extensions/index.js b/lib/extensions/index.js index 653c0a0..9ed9727 100644 --- a/lib/extensions/index.js +++ b/lib/extensions/index.js @@ -1,6 +1,14 @@ 'use strict'; +var PromiseA; +try { + PromiseA = require('bluebird'); +} catch(e) { + PromiseA = global.Promise; +} + var fs = require('fs'); +var sfs = require('safe-replace').create({ tmp: 'tmp', bak: 'bak' }); var path = require('path'); var util = require('util'); var crypto = require('crypto'); @@ -9,13 +17,206 @@ var jwt = require('jsonwebtoken'); var requestAsync = util.promisify(require('@coolaj86/urequest')); var readFileAsync = util.promisify(fs.readFile); var mkdirpAsync = util.promisify(require('mkdirp')); +var TRUSTED_ISSUERS = [ 'oauth3.org' ]; +var DB = {}; +DB._load = function () { + try { + DB._perms = require('./permissions.json'); + } catch(e) { + try { + DB._perms = require('./permissions.json.bak'); + } catch(e) { + DB._perms = []; + } + } + DB._byDomain = {}; + DB._byPort = {}; + DB._byEmail = {}; + DB._byPpid = {}; + DB._byId = {}; + DB._grants = {}; + DB._perms.forEach(function (acc) { + if (acc.id) { + DB._byId[acc.id] = acc; + if (!DB._grants[acc.id]) { + DB._grants[acc.id] = []; + } + acc.domains.forEach(function (d) { + DB._grants[d.name + '|id|' + acc.id] = true + DB._grants[acc.id].push(d); + }); + acc.ports.forEach(function (p) { + DB._grants[p.number + '|id|' + acc.id] = true + DB._grants[acc.id].push(p); + }); + } + acc.nodes.forEach(function (node) { + if ('mailto' === node.scheme || 'email' === node.type) { + if (!DB._grants[node.email]) { + DB._grants[node.email] = []; + } + acc.domains.forEach(function (d) { + DB._grants[d.name + '|' + (node.scheme||node.type) + '|' + node.name] = true + DB._grants[node.email].push(d); + }); + acc.ports.forEach(function (d) { + DB._grants[d.name + '|' + (node.scheme||node.type) + '|' + node.name] = true + DB._grants[node.email].push(p); + }); + DB._byEmail[node.name] = { + account: acc + , node: node + } + } + }); + acc.ppids.forEach(function (node) { + DB._byPpid[node.name] = { + account: acc + , node: node + } + }); + acc.domains.forEach(function (domain) { + if (DB._byDomain[domain.name]) { + console.warn("duplicate domain '" + domain.name + "'"); + console.warn("::existing account '" + acc.nodes.map(function (node) { return node.name; }) + "'"); + console.warn("::new account '" + DB._byDomain[domain.name].account.nodes.map(function (node) { return node.name; }) + "'"); + } + DB._byDomain[domain.name] = { + account: acc + , domain: domain + }; + }); + acc.ports.forEach(function (port) { + if (DB._byPort[port.number]) { + console.warn("duplicate port '" + domain.number + "'"); + console.warn("::existing account '" + acc.nodes.map(function (node) { return node.name; }) + "'"); + console.warn("::new account '" + DB._byPort[port.number].account.nodes.map(function (node) { return node.name; }) + "'"); + } + DB._byPort[domain.name] = { + account: acc + , port: port + }; + }); + }); +}; +DB._load(); +DB.accounts = {}; +DB.accounts.get = function (obj) { + return PromiseA.resolve().then(function () { + return DB._byId[obj.name] || (DB._byEmail[obj.name] || {}).acc || null; + }); +}; +DB.accounts.add = function (obj) { + return PromiseA.resolve().then(function () { + if (obj.id) { + // TODO more checks + DB._perms.push(obj); + } else if (obj.email) { + obj.email = undefined; + DB._perms.push(obj); + } + }); +}; +DB.domains = {}; +DB.domains.available = function (name) { + return PromiseA.resolve().then(function () { + return !DB._byDomain[name]; + }); +}; +DB.domains._add = function (acc, name) { + // TODO verifications to change ownership of a domain + return PromiseA.resolve().then(function () { + var err; + //var acc = DB._byId[aid]; + var domain = { + name: name + , createdAt: new Date().toISOString() + , wildcard: true + }; + var pdomain; + var parts = name.split('.').map(function (el, i) { + return arr.slice(i).join('.'); + }).reverse(); + parts.shift(); + parts.pop(); + if (parts.some(function (part) { + if (DB._byDomain[part]) { + pdomain = part; + return true; + } + })) { + err = new Error("'" + name + "' exists as '" + pdomain + "' and therefore requires an admin to review and approve"); + err.code = "E_REQ_ADMIN"; + throw err; + } + if (DB._byDomain[name]) { + if (acc !== DB._byDomain[name].account) { + throw new Error("domain '" + name + "' exists"); + } + // happily ignore non-change + return; + } + DB._byDomain[name] = { + account: acc + , domain: domain + }; + acc.domains.push(domain); + }); +}; +DB.ports = {}; +DB.ports.available = function (number) { + return PromiseA.resolve().then(function () { + return !DB._byPort[number]; + }); +}; +DB.ports._add = function (acc, number) { + return PromiseA.resolve().then(function () { + //var acc = DB._byId[aid]; + var port = { + number: number + , createdAt: new Date().toISOString() + }; + if (DB._byPort[number]) { + // TODO verifications + throw new Error("port '" + number + "' exists"); + } + DB._byPort[number] = { + account: acc + , domain: domain + }; + acc.domains.push(domain); + }); +}; +DB._save = function () { + return sfs.writeAsync('./accounts.json', JSON.stringify(DB._perms)); +}; +DB._saveToken = null; +DB._savePromises = []; +DB._savePromise = PromiseA.resolve(); +DB.save = function () { + cancelTimeout(DB._saveToken); + return new Promise(function (resolve, reject) { + function doSave() { + DB._savePromise = DB._savePromise.then(function () { + return DB._save().then(function (yep) { + DB._savePromises.forEach(function (p) { + p.resolve(yep); + }); + DB._savePromises.length = 1; + }, function (err) { + DB._savePromises.forEach(function (p) { + p.reject(err); + }); + DB._savePromises.length = 1; + }); + }); + return DB._savePromise; + } -var PromiseA; -try { - PromiseA = require('bluebird'); -} catch(e) { - PromiseA = global.Promise; -} + DB._saveToken = setTimeout(doSave, 2500); + DB.savePromises.push({ resolve: resolve, reject: reject }); + }); +}; var _auths = module.exports._auths = {}; var Auths = {}; @@ -140,15 +341,22 @@ Accounts.create = function (req) { Accounts.link = function (req) { }; */ -Accounts.getBySub = function (req) { + +Accounts.getOrCreate = function (req) { var id = Accounts._getTokenId(req.auth); - var subpath = Accounts._subPath(req, id); - return readFileAsync(path.join(subpath, 'index.json'), 'utf8').then(function (text) { - return JSON.parse(text); - }, function (/*err*/) { - return null; - }).then(function (links) { - return links || { id: id, sub: req.auth.sub, iss: req.auth.iss, accounts: [] }; + var idNode = { type: 'ppid', name: id }; + + return DB.accounts.get(idNode).then(function (acc) { + if (acc) { return _acc; } + acc = { id: id, sub: req.auth.sub, iss: req.auth.iss, domains: [], ports: [], nodes: [ idNode ] }; + return DB.accounts.add(acc).then(function () { + // intentionally not returned to the promise chain + DB.save().catch(function (err) { + console.error('DB.save() failed:'); + console.error(err); + }); + return acc; + }); }); }; @@ -343,6 +551,7 @@ function oauth3Auth(req, res, next) { , json: true }).then(function (resp) { var jwk = resp.body; + console.log('Retrieved token\'s JWK: ', resp.body); if (200 !== resp.statusCode || 'object' !== typeof resp.body) { //headers.authorization res.send({ @@ -362,6 +571,7 @@ function oauth3Auth(req, res, next) { try { pubpem = require('jwk-to-pem')(jwk, { private: false }); } catch(e) { + console.error("jwk-to-pem", e); pubpem = null; } return verifyJwt(token, pubpem, { @@ -382,7 +592,7 @@ function oauth3Auth(req, res, next) { next(); }); }); - }, function (err) { + }).catch(function (err) { res.send({ error: { code: err.code || "E_GENERIC" @@ -391,6 +601,13 @@ function oauth3Auth(req, res, next) { }); }); } +var OAUTH3 = require('oauth3.js').create({ pathname: process.cwd() }); +/* +// TODO all of the above should be replace with the official lib +return OAUTH3.jwk.verifyToken(req.auth.jwt).then(function (token) { +}).catch(function (err) { +}); +*/ module.exports.pairRequest = function (opts) { console.log("It's auth'n time!"); @@ -433,6 +650,50 @@ module.exports.pairRequest = function (opts) { return authnData; }); }; +DB.getDomainAndPort = function (state) { + var domainCount = 0; + var portCount = 0; + + function chooseDomain() { + var err; + if (domainCount >= 3) { + err = new Error("there too few unallocated domains left"); + err.code = "E_DOMAINS_EXHAUSTED"; + return PromiseA.reject(err); + } + domainCount += 1; + var hri = require('human-readable-ids').hri; + var i = Math.floor(Math.random() * state.config.sharedDomains.length); + var hrname = hri.random() + '.' + state.config.sharedDomains[i]; + return DB.domains.available(hrname).then(function (available) { + if (!available) { return chooseDomain(); } + return hrname; + }); + } + function choosePort() { + var err; + if (portCount >= 3) { + err = new Error("there too few unallocated ports left"); + err.code = "E_PORTS_EXHAUSTED"; + return PromiseA.reject(err); + } + portCount += 1; + var portnumber = (1024 + 1) + Math.round(Math.random() * 65535); + return DB.ports.available(portnumber).then(function (available) { + if (!available) { return portDomain(); } + return portnumber; + }); + } + return Promise.all([ + chooseDomain() + , choosePort() + ]).then(function (two) { + return { + domain: two[0] + , port: two[1] + }; + }); +}; module.exports.pairPin = function (opts) { var state = opts.state; return state.Promise.resolve().then(function () { @@ -455,36 +716,63 @@ module.exports.pairPin = function (opts) { } console.log('[pairPin] generating offer'); - var hri = require('human-readable-ids').hri; - var i = Math.floor(Math.random() * state.config.sharedDomains.length); - var hrname = hri.random() + '.' + state.config.sharedDomains[i]; - // TODO check used / unused names and ports - var authzData = { - id: auth.id - , domains: [ hrname ] - , ports: [ (1024 + 1) + Math.round(Math.random() * 65535) ] - , aud: state.config.webminDomain - , iat: Math.round(Date.now() / 1000) - , hostname: auth.hostname - }; + return DB.getDomainAndPort(state); + }).then(function (grantable) { + var emailNode = { scheme: 'mailto', type: 'email', name: auth.subject }; + + return DB.accounts.get(emailNode).then(function (_acc) { + var acc = _acc; + if (!acc) { + acc = { email: true, domains: [], ports: [], nodes: [ emailNode ] }; + } + return PromiseA.all([ + DB.domains._add(acc, opts.domain) + , DB.ports._add(acc, opts.port) + ]).then(function () { + var authzData = { + id: auth.id + , domains: [ grantable.domain ] + , ports: [ grantable.port ] + , aud: state.config.webminDomain + , iat: Math.round(Date.now() / 1000) + // of the client's computer + , hostname: auth.hostname + }; + auth.authz = jwt.sign(authzData, state.secret); + auth.authzData = authzData; + authzData.jwt = auth.authz; + auth._offered = authzData; + if (auth.resolve) { + console.log('[pairPin] resolving'); + auth.resolve(auth); + } else { + console.log('[pairPin] not resolvable'); + } + + if (!_acc) { + return DB.accounts.add(acc).then(function () { + // intentionally not returned to the promise chain + DB.save().catch(function (err) { + console.error('DB.save() failed:'); + console.error(err); + }); + return authzData; + }); + } else { + return authzData; + } + }); + }); + + /* var pathname = path.join(__dirname, 'emails', auth.subject + '.' + hrname + '.data'); - auth.authz = jwt.sign(authzData, state.secret); - auth.authzData = authzData; - authzData.jwt = auth.authz; - auth._offered = authzData; - if (auth.resolve) { - console.log('[pairPin] resolving'); - auth.resolve(auth); - } else { - console.log('[pairPin] not resolvable'); - } fs.writeFile(pathname, JSON.stringify(authzData), function (err) { if (err) { console.error('[ERROR] in writing token details'); console.error(err); } }); - return authzData; + */ }); }; @@ -620,11 +908,81 @@ app.use('/api', CORS({ app.use('/api', bodyParser.json()); app.use('/api/telebit.cloud/account', oauth3Auth); +Accounts._associateEmails = function (req) { + if (-1 === (req._state.config.trustedIssuers||TRUSTED_ISSUERS).indexOf(req.auth.data.iss)) { + // again, make sure that untrusted issuers do not get + return null; + } + + // oauth3.org, issuer@oauth3.org, profile + return OAUTH3.request({ + url: "https://api." + req.auth.data.iss + "/api/issuer@oauth3.org/acl/profile" + , session: { accessToken: req.auth.jwt } + }).then(function (resp) { + var email; + var err; + (resp.data.nodes||[]).some(function (node) { + // TODO use verified email addresses + return true + }); + // back-compat for current way email is stored + if (!email && /@/.test(resp.data.username)) { + email = resp.data.username; + } + if (!email) { + err = new Error ("could not find a verified email address in profile settings"); + err.code = "E_NO_EMAIL" + return PromiseA.reject(err); + } + + return [ { scheme: 'mailto', type: 'email', name: email } ]; + }); +}; app.get('/api/telebit.cloud/account', function (req, res) { - Accounts.getBySub(req).then(function (subData) { - res.send(subData); - }, function (err) { - res.send({ + return Accounts.getOrCreate(req).then(function (acc) { + var hasEmail = subData.nodes.some(function (node) { + return 'email' === node.type; + }); + function getAllGrants() { + return PromiseA.all(acc.nodes.map(function (node) { + return DB.accounts.get(node); + })).then(function (grants) { + var domainsMap = {}; + var portsMap = {}; + var result = JSON.parse(JSON.stringify(acc)); + result.domains.length = 0; + result.ports.length = 0; + grants.forEach(function (account) { + account.domains.forEach(function (d) { + domainsMap[d.name] = d; + }); + account.ports.forEach(function (p) { + portsMap[p.number] = p; + }); + }); + result.domains = Object.keys(domainsMap).map(function (k) { + return domainsMap[k]; + }); + result.ports = Object.keys(portsMap).map(function (k) { + return portsMap[k]; + }); + return result; + }); + } + if (!hasEmail) { + return Accounts._associateEmails(req).then(function (nodes) { + nodes.forEach(function (node) { + acc.nodes.push(node); + }); + return getAllGrants(); + }); + } else { + return getAllGrants(); + } + }).then(function (result) { + res.send(result); + }).catch(function (err) { + return res.send({ error: { code: err.code || "E_GENERIC" , message: err.toString() diff --git a/lib/extensions/package.json b/lib/extensions/package.json new file mode 100644 index 0000000..6c43a8d --- /dev/null +++ b/lib/extensions/package.json @@ -0,0 +1,16 @@ +{ + "name": "telebit.commercial", + "version": "1.0.0", + "private": true, + "description": "Commercial node.js APIs for Telebit", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "jwk-to-pem": "^2.0.0", + "oauth3.js": "^1.2.5" + } +}