diff --git a/lib/extensions/admin/account.html b/lib/extensions/admin/account.html index 1fd078e..a0405a6 100644 --- a/lib/extensions/admin/account.html +++ b/lib/extensions/admin/account.html @@ -16,16 +16,25 @@

Account

-
+ Add a custom domain:
-
+ Authorize another email:
+

Claims

+
    +
  1. + {{ claim.value }} + TXT _claim-challenge.{{ claim.value }}: {{ claim.challenge }} + +
  2. +
+

Domains

  1. {{ domain }} diff --git a/lib/extensions/admin/js/account.js b/lib/extensions/admin/js/account.js index 805d421..a11a38c 100644 --- a/lib/extensions/admin/js/account.js +++ b/lib/extensions/admin/js/account.js @@ -1,3 +1,4 @@ +/*global Vue*/ (function () { 'use strict'; var OAUTH3 = window.OAUTH3; @@ -5,33 +6,7 @@ host: window.location.host , pathname: window.location.pathname.replace(/\/[^\/]*$/, '/') }); - var $ = function () { return document.querySelector.apply(document, arguments); } - var vueData = { - domains: [] - , newDomain: null - , newEmail: null - , hasAccount: false - , token: null - }; - var app = new Vue({ - el: '.v-app' - , data: vueData - , methods: { - challengeDns: function () { - console.log("A new (DNS) challenger!", vueData); - } - , challengeEmail: function () { - console.log("A new (Email) challenger!", vueData); - } - } - }); - - function listStuff(data) { - //window.alert("TODO: show authorized devices, domains, and connectivity information"); - vueData.hasAccount = true; - vueData.domains = data.domains; - } - + var $ = function () { return document.querySelector.apply(document, arguments); }; var sessionStr = localStorage.getItem('session'); var session; if (sessionStr) { @@ -42,6 +17,49 @@ } } + var vueData = { + claims: [] + , domains: [] + , newDomain: null + , newDomainWildcard: false + , newEmail: null + , hasAccount: false + , token: null + }; + var app = new Vue({ + el: '.v-app' + , data: vueData + , methods: { + challengeDns: function () { + return oauth3.request({ + url: 'https://api.' + location.hostname + '/api/telebit.cloud/account/authorizations/new' + , method: 'POST' + , session: session + , data: { type: 'dns', value: vueData.newDomain, wildcard: vueData.newDomainWildcard } + }); + } + , checkDns: function (claim) { + return oauth3.request({ + url: 'https://api.' + location.hostname + '/api/telebit.cloud/account/authorizations/new/:value/:challenge' + .replace(/:value/g, claim.value) + .replace(/:challenge/g, claim.challenge) + , method: 'POST' + , session: session + }); + } + , challengeEmail: function () { + console.log("A new (Email) challenger!", vueData); + } + } + }); + app = null; + + function listStuff(data) { + //window.alert("TODO: show authorized devices, domains, and connectivity information"); + vueData.hasAccount = true; + vueData.domains = data.domains; + vueData.claims = data.claims; + } function loadAccount(session) { return oauth3.request({ url: 'https://api.' + location.hostname + '/api/telebit.cloud/account' @@ -64,7 +82,7 @@ , method: 'POST' , session: session , body: { - email: email + email: vueData.newEmail } }).then(function (resp) { listStuff(resp); @@ -124,7 +142,7 @@ console.log(resp.data); localStorage.setItem('session', JSON.stringify(session)); - loadAccount(session) + loadAccount(session); }); }); @@ -138,7 +156,7 @@ $('body form.js-auth-form').addEventListener('submit', onClickLogin); onChangeProvider('oauth3.org'); if (session) { - vueData.token = session.access_token + vueData.token = session.access_token; loadAccount(session); } }()); diff --git a/lib/extensions/db.js b/lib/extensions/db.js index de72d8f..40cff60 100644 --- a/lib/extensions/db.js +++ b/lib/extensions/db.js @@ -29,7 +29,12 @@ DB._load = function () { DB._byId = {}; DB._grants = {}; DB._grantsMap = {}; + DB._authz = {}; DB._perms.forEach(function (acc) { + if ('authz' === acc.type) { + DB._authz[acc.id] = acc; + return; + } if (acc.id) { // if account has an id DB._byId[acc.id] = acc; @@ -129,6 +134,80 @@ DB.accounts.add = function (obj) { } }); }; +DB.authorizations = {}; +DB.authorizations.create = function (acc, claim) { + if (!acc.id || !claim.type || !claim.value) { throw new Error("requires account id"); } + var crypto = require('crypto'); + var authz = DB._authz[acc.id]; + if (!authz) { + authz = { + id: acc.id + , type: 'authz' + , claims: [] + }; + DB._authz[acc.id] = authz; + DB._perms.push(authz); + } + // TODO check for unique type:value pairing in claims + claim.challenge = crypto.randomBytes(16).toString('hex'); + claim.createdAt = Date.now(); + claim.verifiedAt = 0; + authz.claims.push(claim); + DB.save(); + return JSON.parse(JSON.stringify(claim)); +}; +DB.authorizations.check = function (acc, claim) { + var authz = DB._authz[acc.id]; + var vclaim = null; + if (!authz) { + return vclaim; + } + + authz.claims.some(function (c) { + console.log('authz.check', c); + if (claim.challenge) { + if (c.challenge === claim.challenge) { + vclaim = JSON.parse(JSON.stringify(c)); + return true; + } + } else if (claim.value === c.value) { + vclaim = JSON.parse(JSON.stringify(c)); + return true; + } + }); + return vclaim; +}; +DB.authorizations.checkAll = function (acc) { + var authz = DB._authz[acc.id]; + if (!authz) { + return []; + } + + return authz.claims.map(function (claim) { + return JSON.parse(JSON.stringify(claim)); + }); +}; +DB.authorizations.verify = function (acc, claim) { + var scmp = require('scmp'); + var authz = DB._authz[acc.id]; + var vclaim; + if (!authz) { return false; } + + authz.claims.some(function (c) { + if (scmp(c.challenge, claim.challenge)) { + vclaim = c; + c.verifiedAt = Date.now(); + return true; + } + }); + + if (vclaim) { + DB.save(); + return true; + } + + return false; +}; DB.domains = {}; DB.domains.available = function (name) { return PromiseA.resolve().then(function () { @@ -154,7 +233,7 @@ DB.domains._add = function (acc, opts) { parts.shift(); parts.pop(); if (parts.some(function (part) { - if (DB._byDomain[part]) { + if (DB._byDomain[part] && DB._byDomain[part].wildcard) { pdomain = part; return true; } @@ -175,6 +254,7 @@ DB.domains._add = function (acc, opts) { , domain: domain }; acc.domains.push(domain); + DB.save(); }); }; DB.ports = {}; diff --git a/lib/extensions/index.js b/lib/extensions/index.js index a4cfec4..0e27f6e 100644 --- a/lib/extensions/index.js +++ b/lib/extensions/index.js @@ -19,6 +19,15 @@ var requestAsync = util.promisify(require('@coolaj86/urequest')); var mkdirpAsync = util.promisify(require('mkdirp')); var TRUSTED_ISSUERS = [ 'oauth3.org' ]; var DB = require('./db.js'); +var Claims = {}; +Claims.publicize = function publicizeClaim(claim) { + if (!claim) { + return null; + } + var result = { type: claim.type, value: claim.value, verifiedAt: claim.verifiedAt, createdAt: claim.createdAt }; + if ('dns' === claim.type) { result.challenge = claim.challenge; } + return result; +}; var _auths = module.exports._auths = {}; var Auths = {}; @@ -763,6 +772,7 @@ app.get('/api/telebit.cloud/account', function (req, res) { //console.log(grants); var domainsMap = {}; var portsMap = {}; + var claimsMap = {}; var result = JSON.parse(JSON.stringify(acc)); result.domains.length = 0; result.ports.length = 0; @@ -775,6 +785,11 @@ app.get('/api/telebit.cloud/account', function (req, res) { account.ports.forEach(function (p) { portsMap[p.number] = p; }); + DB.authorizations.checkAll({ id: account.id }).filter(function (claim) { + return !claim.verifiedAt; + }).forEach(function (claim) { + claimsMap[claim.challenge] = claim; + }); }); result.domains = Object.keys(domainsMap).map(function (k) { return domainsMap[k]; @@ -782,6 +797,9 @@ app.get('/api/telebit.cloud/account', function (req, res) { result.ports = Object.keys(portsMap).map(function (k) { return portsMap[k]; }); + result.claims = Object.keys(claimsMap).map(function (k) { + return Claims.publicize(claimsMap[k]); + }); return result; }); } @@ -821,24 +839,121 @@ app.post('/api/telebit.cloud/account', function (req, res) { // Challenge Nodes / Email, Domains / DNS app.post('/api/telebit.cloud/account/authorizations/new', function (req, res) { // Send email via SMTP, confirm client's chosen pin - res.statusCode = 500; - res.send({ error: { code: "E_NO_IMPL", message: "not implemented" } }); + var accId = Accounts._getTokenId(req.auth); + var typ = req.body.type; + var val = req.body.value; + var wild = req.body.wildcard; + var claim; + + if ('dns' === typ && /^[a-z0-9\-\.]+.[a-z]+$/i.test(val)) { + claim = DB.authorizations.create({ id: accId }, { type: typ, value: val, wildcard: wild }); + // MUST RETURN PUBLIC VALUES ONLY! + // (challenge is public with dns because the verification is internal) + res.send({ success: true, claim: claim }); + } else if ('email' === typ) { + //claim = DB.authorizations.create({ type: dns, claim: claim }); + // MUST RETURN PUBLIC VALUES ONLY! + // (challenge is private with email because the verification is external) + //claim.challenge = undefined;; + // TODO send email + res.statusCode = 501; + res.send({ error: { code: "E_NO_IMPL", message: "authz '" + typ + "' understood but not implemented" } }); + } else { + res.statusCode = 501; + res.send({ error: { code: "E_NO_IMPL", message: "unknown authz type '" + typ + "'" } }); + } }); -app.get('/api/telebit.cloud/account/authorizations/status/:id', function (req, res) { +app.get('/api/telebit.cloud/account/authorizations/status/:value?', function (req, res) { // For client to check on status - res.statusCode = 500; - res.send({ error: { code: "E_NO_IMPL", message: "not implemented" } }); + var accId = Accounts._getTokenId(req.auth); + var val = req.params.value; + var result; + + if (val) { + result = Claims.publicize(DB.authorizations.check({ id: accId }, { value: val })); + // MUST RETURN PUBLIC VALUES ONLY! + res.send({ success: true, claim: result }); + } else { + result = DB.authorizations.checkAll({ id: accId }).map(Claims.publicize); + // MUST RETURN PUBLIC VALUES ONLY! + res.send({ success: true, claims: result }); + } }); app.get('/api/telebit.cloud/account/authorizations/meta/:secret', function (req, res) { // For agent to retrieve metadata res.statusCode = 500; res.send({ error: { code: "E_NO_IMPL", message: "not implemented" } }); }); -app.post('/api/telebit.cloud/account/authorizations/new/:magic/:pin', function (req, res) { +app.post('/api/telebit.cloud/account/authorizations/verify/:magic/:pin', function (req, res) { // For agent to confirm user's intent res.statusCode = 500; res.send({ error: { code: "E_NO_IMPL", message: "not implemented" } }); }); +app.post('/api/telebit.cloud/account/authorizations/new/:value/:challenge?', function (req, res) { + // For agent to confirm user's intent + var dns = require('dns'); + var accId = Accounts._getTokenId(req.auth); + var val = req.params.value; + var ch = req.params.challenge; + var claim = DB.authorizations.check({ id: accId }, { challenge: ch, value: val }); + + function notFound() { + res.send({ error: { + code: "E_PENDING" + , message: "Did not find '" + claim.challenge + "' among records at '_claim-challenge." + claim.value + "'" + } }); + } + + function grantDnsClaim() { + return Accounts.getOrCreate(req).then(function (acc) { + return DB.domains._add(acc, { domain: claim.value, wildcard: claim.wildcard }).then(function (result) { + if (!DB.authorizations.verify({ id: accId }, claim)) { + var err = new Error("'_claim-challenge." + claim.value + "' matched, but final verification failed"); + err.code = "E_UNKNOWN"; + return PromiseA.reject(err); + } + return result; + }); + }); + } + + function checkDns() { + dns.resolveTxt('_claim-challenge.' + claim.value, function (err, records) { + if (err) { + notFound(); + return; + } + + if (!records.some(function (txts) { + return txts.some(function (txt) { + console.log('TXT', txt); + return claim.challenge === txt; + }); + })) { + notFound(); + return; + } + + grantDnsClaim().then(function () { + res.send({ success: true }); + }).catch(function (err) { + res.send({ error: { code: err.code, message: err.toString(), _stack: err.stack } }); + }); + }); + } + + console.log('claim', claim); + + if ('dns' === claim.type) { + checkDns(); + } else if ('email' === claim.type) { + res.statusCode = 500; + res.send({ error: { code: "E_NO_IMPL", message: "'" + claim.type + "' not implemented yet" } }); + } else { + res.statusCode = 500; + res.send({ error: { code: "E_NO_IMPL", message: "'" + claim.type + "' not understood" } }); + } +}); // From Device (which knows id, but not secret) diff --git a/lib/extensions/package.json b/lib/extensions/package.json index 6c43a8d..7921cae 100644 --- a/lib/extensions/package.json +++ b/lib/extensions/package.json @@ -11,6 +11,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "jwk-to-pem": "^2.0.0", - "oauth3.js": "^1.2.5" + "oauth3.js": "^1.2.5", + "scmp": "^1.0.2" } }