WIP implementing semi-proper accounts

This commit is contained in:
AJ ONeal 2018-08-04 10:21:06 +00:00
parent 1099a75509
commit 64281e4c93
3 changed files with 470 additions and 69 deletions

View File

@ -3,8 +3,9 @@
<title>Telebit Account</title> <title>Telebit Account</title>
</head> </head>
<body> <body>
<h1>Login</h1>
<form class="js-auth-form"> <form class="js-auth-form">
<input class="js-auth-subject" type="email"/> <input class="js-auth-subject" placeholder="email" type="email"/>
<button class="js-auth-submit" type="submit">Login</button> <button class="js-auth-submit" type="submit">Login</button>
</form> </form>
@ -30,9 +31,12 @@
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
var email = $('.js-auth-subject').value;
// TODO check subject for provider viability // TODO check subject for provider viability
return oauth3.authenticate({ return oauth3.authenticate({
subject: $('.js-auth-subject').value subject: email
, scope: 'email@oauth3.org'
}).then(function (session) { }).then(function (session) {
console.info('Authentication was Successful:'); console.info('Authentication was Successful:');
@ -44,6 +48,10 @@
// //
console.info('Secure PPID (aka subject):', session.token.sub); console.info('Secure PPID (aka subject):', session.token.sub);
function listStuff() {
window.alert("TODO: show authorized devices, domains, and connectivity information");
}
return oauth3.request({ return oauth3.request({
url: 'https://api.oauth3.org/api/issuer@oauth3.org/jwks/:sub/:kid.json' url: 'https://api.oauth3.org/api/issuer@oauth3.org/jwks/:sub/:kid.json'
.replace(/:sub/g, session.token.sub) .replace(/:sub/g, session.token.sub)
@ -62,13 +70,32 @@
console.log(resp.data); console.log(resp.data);
return oauth3.request({ return oauth3.request({
url: 'https://api.telebit.cloud/api/telebit.cloud/account' url: 'https://api.' + location.hostname + '/api/telebit.cloud/account'
, session: session , session: session
}).then(function (resp) { }).then(function (resp) {
console.info("Telebit Account:"); console.info("Telebit Account:");
console.log(resp.data); 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.");
}
}); });

View File

@ -1,6 +1,14 @@
'use strict'; 'use strict';
var PromiseA;
try {
PromiseA = require('bluebird');
} catch(e) {
PromiseA = global.Promise;
}
var fs = require('fs'); var fs = require('fs');
var sfs = require('safe-replace').create({ tmp: 'tmp', bak: 'bak' });
var path = require('path'); var path = require('path');
var util = require('util'); var util = require('util');
var crypto = require('crypto'); var crypto = require('crypto');
@ -9,13 +17,206 @@ var jwt = require('jsonwebtoken');
var requestAsync = util.promisify(require('@coolaj86/urequest')); var requestAsync = util.promisify(require('@coolaj86/urequest'));
var readFileAsync = util.promisify(fs.readFile); var readFileAsync = util.promisify(fs.readFile);
var mkdirpAsync = util.promisify(require('mkdirp')); var mkdirpAsync = util.promisify(require('mkdirp'));
var TRUSTED_ISSUERS = [ 'oauth3.org' ];
var PromiseA; var DB = {};
DB._load = function () {
try { try {
PromiseA = require('bluebird'); DB._perms = require('./permissions.json');
} catch(e) { } catch(e) {
PromiseA = global.Promise; 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;
}
DB._saveToken = setTimeout(doSave, 2500);
DB.savePromises.push({ resolve: resolve, reject: reject });
});
};
var _auths = module.exports._auths = {}; var _auths = module.exports._auths = {};
var Auths = {}; var Auths = {};
@ -140,15 +341,22 @@ Accounts.create = function (req) {
Accounts.link = function (req) { Accounts.link = function (req) {
}; };
*/ */
Accounts.getBySub = function (req) {
Accounts.getOrCreate = function (req) {
var id = Accounts._getTokenId(req.auth); var id = Accounts._getTokenId(req.auth);
var subpath = Accounts._subPath(req, id); var idNode = { type: 'ppid', name: id };
return readFileAsync(path.join(subpath, 'index.json'), 'utf8').then(function (text) {
return JSON.parse(text); return DB.accounts.get(idNode).then(function (acc) {
}, function (/*err*/) { if (acc) { return _acc; }
return null; acc = { id: id, sub: req.auth.sub, iss: req.auth.iss, domains: [], ports: [], nodes: [ idNode ] };
}).then(function (links) { return DB.accounts.add(acc).then(function () {
return links || { id: id, sub: req.auth.sub, iss: req.auth.iss, accounts: [] }; // 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 , json: true
}).then(function (resp) { }).then(function (resp) {
var jwk = resp.body; var jwk = resp.body;
console.log('Retrieved token\'s JWK: ', resp.body);
if (200 !== resp.statusCode || 'object' !== typeof resp.body) { if (200 !== resp.statusCode || 'object' !== typeof resp.body) {
//headers.authorization //headers.authorization
res.send({ res.send({
@ -362,6 +571,7 @@ function oauth3Auth(req, res, next) {
try { try {
pubpem = require('jwk-to-pem')(jwk, { private: false }); pubpem = require('jwk-to-pem')(jwk, { private: false });
} catch(e) { } catch(e) {
console.error("jwk-to-pem", e);
pubpem = null; pubpem = null;
} }
return verifyJwt(token, pubpem, { return verifyJwt(token, pubpem, {
@ -382,7 +592,7 @@ function oauth3Auth(req, res, next) {
next(); next();
}); });
}); });
}, function (err) { }).catch(function (err) {
res.send({ res.send({
error: { error: {
code: err.code || "E_GENERIC" 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) { module.exports.pairRequest = function (opts) {
console.log("It's auth'n time!"); console.log("It's auth'n time!");
@ -433,6 +650,50 @@ module.exports.pairRequest = function (opts) {
return authnData; 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) { module.exports.pairPin = function (opts) {
var state = opts.state; var state = opts.state;
return state.Promise.resolve().then(function () { return state.Promise.resolve().then(function () {
@ -455,19 +716,28 @@ module.exports.pairPin = function (opts) {
} }
console.log('[pairPin] generating offer'); console.log('[pairPin] generating offer');
var hri = require('human-readable-ids').hri; return DB.getDomainAndPort(state);
var i = Math.floor(Math.random() * state.config.sharedDomains.length); }).then(function (grantable) {
var hrname = hri.random() + '.' + state.config.sharedDomains[i]; var emailNode = { scheme: 'mailto', type: 'email', name: auth.subject };
// TODO check used / unused names and ports
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 = { var authzData = {
id: auth.id id: auth.id
, domains: [ hrname ] , domains: [ grantable.domain ]
, ports: [ (1024 + 1) + Math.round(Math.random() * 65535) ] , ports: [ grantable.port ]
, aud: state.config.webminDomain , aud: state.config.webminDomain
, iat: Math.round(Date.now() / 1000) , iat: Math.round(Date.now() / 1000)
// of the client's computer
, hostname: auth.hostname , hostname: auth.hostname
}; };
var pathname = path.join(__dirname, 'emails', auth.subject + '.' + hrname + '.data');
auth.authz = jwt.sign(authzData, state.secret); auth.authz = jwt.sign(authzData, state.secret);
auth.authzData = authzData; auth.authzData = authzData;
authzData.jwt = auth.authz; authzData.jwt = auth.authz;
@ -478,13 +748,31 @@ module.exports.pairPin = function (opts) {
} else { } else {
console.log('[pairPin] not resolvable'); 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');
fs.writeFile(pathname, JSON.stringify(authzData), function (err) { fs.writeFile(pathname, JSON.stringify(authzData), function (err) {
if (err) { if (err) {
console.error('[ERROR] in writing token details'); console.error('[ERROR] in writing token details');
console.error(err); console.error(err);
} }
}); });
return authzData; */
}); });
}; };
@ -620,11 +908,81 @@ app.use('/api', CORS({
app.use('/api', bodyParser.json()); app.use('/api', bodyParser.json());
app.use('/api/telebit.cloud/account', oauth3Auth); 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) { app.get('/api/telebit.cloud/account', function (req, res) {
Accounts.getBySub(req).then(function (subData) { return Accounts.getOrCreate(req).then(function (acc) {
res.send(subData); var hasEmail = subData.nodes.some(function (node) {
}, function (err) { return 'email' === node.type;
res.send({ });
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: { error: {
code: err.code || "E_GENERIC" code: err.code || "E_GENERIC"
, message: err.toString() , message: err.toString()

View File

@ -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"
}
}