diff --git a/lib/extensions/admin/login/index.html b/lib/extensions/admin/login/index.html
index 6c18266..ff77d12 100644
--- a/lib/extensions/admin/login/index.html
+++ b/lib/extensions/admin/login/index.html
@@ -13,14 +13,49 @@
-
-
Give us about 30 seconds...
- We're initializing our connection, redirecting you to your device at
-
{{js-new-href}}
- which will then take a few seconds to initialize as it gets your https certificates for peer-to-peer, end-to-end encryption
-
-
-
{{js-token-data}}
+
+
+
+
+
Telebit Authorized
+
+
Waiting for your device to connect...
+
Check your device to complete the pairing.
+
+
🔒 xxx-xxx-xxx.example.com
+
When your device is paired you will be redirected to
+ {{js-new-href}}.
+
+
+
xxxxx
+
When your device is paired you will be able to use xxxxx
+ for SSH, and other TCP protocols.
+
telebit ssh auto
+
+ssh {{servername}} -p {{serviceport}}
+
+
+
Authorization Token
+
{{js-token}}
+
diff --git a/lib/extensions/admin/login/js/app.js b/lib/extensions/admin/login/js/app.js
index 7270d64..c8ebd32 100644
--- a/lib/extensions/admin/login/js/app.js
+++ b/lib/extensions/admin/login/js/app.js
@@ -1,29 +1,118 @@
(function () {
'use strict';
-var magic = (window.location.hash || '').substr(2).replace(/magic=/, '');
+var meta = {};
+var magic;
-if (magic) {
- window.fetch('https://api.' + location.hostname + '/api/telebit.cloud/magic/' + magic, {
+function checkStatus() {
+ // TODO use Location or Link
+ window.fetch(meta.baseUrl + 'api/telebit.cloud/pair_state/' + magic, {
method: 'GET'
, cors: true
}).then(function (resp) {
- return resp.json().then(function (json) {
- if (json.error) {
+ return resp.json().then(function (data) {
+ console.log(data);
+ }, function (err) {
+ console.error(err);
+ }).then(function () {
+ setTimeout(checkStatus, 2 * 1000);
+ });
+ });
+}
+
+function submitCode(pair) {
+ // TODO use Location or Link
+ document.querySelector('.js-magic').hidden = true;
+ window.fetch(meta.baseUrl + 'api/telebit.cloud/pair_code/', {
+ method: 'POST'
+ , headers: {
+ 'Content-Type': 'application/json'
+ }
+ , body: JSON.stringify({
+ magic: pair.magic
+ , pin: pair.pin || pair.code
+ , agree_tos: pair.agreeTos
+ })
+ , cors: true
+ }).then(function (resp) {
+ return resp.json().then(function (data) {
+ setTimeout(checkStatus, 0);
+ document.querySelector('.js-authz').hidden = false;
+ console.log(data);
+ /*
+ document.querySelectorAll('.js-token-data').forEach(function ($el) {
+ $el.innerText = JSON.stringify(data, null, 2);
+ });
+ */
+ document.querySelectorAll('.js-new-href').forEach(function ($el) {
+ $el.href = 'https://' + data.domains[0] + '/';
+ $el.innerText = '🔐 https://' + data.domains[0];
+ });
+ document.querySelectorAll('.js-domainname').forEach(function ($el) {
+ $el.innerText = data.domains.join(',');
+ });
+ document.querySelectorAll('.js-serviceport').forEach(function ($el) {
+ $el.innerText = data.ports.join(',');
+ });
+ document.querySelectorAll('.js-token').forEach(function ($el) {
+ $el.innerText = data.jwt;
+ });
+ }, function (err) {
+ console.error(err);
+ document.querySelector('.js-error').hidden = false;
+ });
+ });
+}
+
+function init() {
+ magic = (window.location.hash || '').substr(2).replace(/magic=/, '');
+
+ if (!magic) {
+ document.querySelector('body').hidden = false;
+ document.querySelector('.js-error').hidden = false;
+ }
+
+ window.fetch(meta.baseUrl + meta.pair_request.pathname + '/' + magic, {
+ method: 'GET'
+ , cors: true
+ }).then(function (resp) {
+ return resp.json().then(function (data) {
+ console.log('Data:');
+ console.log(data);
+ if (data.error) {
document.querySelector('.js-error').hidden = false;
document.querySelector('.js-magic-link').innerText = magic;
return;
}
document.querySelector('body').hidden = false;
document.querySelector('.js-magic').hidden = false;
- document.querySelector('.js-token-data').innerText = JSON.stringify(json, null, 2);
- document.querySelector('.js-new-href').href = json.domains[0];
- document.querySelector('.js-new-href').innerText = json.domains[0];
+ document.querySelector('.js-hostname').innerText = data.hostname || 'Device';
+ //document.querySelector('.js-token-data').innerText = JSON.stringify(data, null, 2);
});
});
-} else {
- document.querySelector('body').hidden = false;
- document.querySelector('.js-error').hidden = false;
+
+ document.querySelector('.js-submit').addEventListener('submit', function (ev) {
+ ev.preventDefault();
+ var pair = {};
+ pair.magic = magic;
+ pair.code = document.querySelector('[name=pair-code]').value;
+ pair.agreeTos = document.querySelector('[name=letsencrypt-agree]').checked
+ && document.querySelector('[name=telebit-agree]').checked;
+ console.log('Pair Form:');
+ console.log(pair);
+ submitCode(pair);
+ });
}
+window.fetch('https://' + location.hostname + '/_apis/telebit.cloud/index.json', {
+ method: 'GET'
+, cors: true
+}).then(function (resp) {
+ return resp.json().then(function (_json) {
+ meta = _json;
+ meta.baseUrl = 'https://' + meta.api_host.replace(/:hostname/g, location.hostname) + '/';
+ init();
+ });
+});
+
}());
diff --git a/lib/extensions/index.js b/lib/extensions/index.js
index 90b2043..45da93a 100644
--- a/lib/extensions/index.js
+++ b/lib/extensions/index.js
@@ -9,6 +9,73 @@ var jwt = require('jsonwebtoken');
var requestAsync = util.promisify(require('request'));
var _auths = module.exports._auths = {};
+var Auths = {};
+Auths._no_pin = {
+ toString: function () {
+ return Math.random().toString();
+ }
+};
+Auths.get = function (idOrSecret) {
+ var auth = _auths[idOrSecret];
+ if (!auth) { return; }
+ if (auth.exp && auth.exp < Date.now()) { return; }
+ return auth;
+};
+Auths.getBySecret = function (secret) {
+ var auth = Auths.get(secret);
+ if (!auth) { return; }
+ if (!crypto.timingSafeEqual(
+ Buffer.from(auth.secret.padStart(127, ' '))
+ , Buffer.from((secret || '').padStart(127, ' '))
+ )) {
+ return;
+ }
+ return auth;
+};
+Auths.getBySecretAndPin = function (secret, pin) {
+ var auth = Auths.getBySecret(secret);
+ if (!auth) { return; }
+
+ // TODO v1.0.0 : Security XXX : clients must define a pin
+
+ // 1. Check if the client defined a pin (it should)
+ if (auth.pin === Auths._no_pin) {
+ // 2. If the browser defined a pin, it should be some variation of 000 000
+ if (pin && 0 !== parseInt(pin, 10)) { return; }
+
+ } else if (!crypto.timingSafeEqual(
+ Buffer.from(auth.pin.toString().padStart(127, ' '))
+ , Buffer.from((pin || '').padStart(127, ' '))
+ )) {
+ // 3. The client defined a pin and it doesn't match what the browser defined
+ return;
+ }
+
+ return auth;
+};
+Auths.set = function (auth, id, secret) {
+ auth.id = auth.id || id || crypto.randomBytes(12).toString('hex');
+ auth.secret = auth.secret || secret || crypto.randomBytes(12).toString('hex');
+ _auths[auth.id] = auth;
+ _auths[auth.secret] = auth;
+ return auth;
+};
+Auths._clean = function () {
+ Object.keys(_auths).forEach(function (key) {
+ var err;
+ if (_auths[key]) {
+ if (_auths[key].exp < Date.now()) {
+ if ('function' === typeof _auths[key].reject) {
+ err = new Error("Login Failure: Magic Link was not clicked within 5 minutes");
+ err.code = 'E_LOGIN_TIMEOUT';
+ _auths[key].reject(err);
+ }
+ _auths[key] = null;
+ delete _auths[key];
+ }
+ }
+ });
+};
function sendMail(state, auth) {
console.log('[DEBUG] ext auth', auth);
@@ -58,7 +125,8 @@ function sendMail(state, auth) {
, html: html
}
}).then(function (resp) {
- fs.writeFile(path.join(__dirname, 'emails', auth.subject), JSON.stringify(auth), function (err) {
+ var pathname = path.join(__dirname, 'emails', auth.subject);
+ fs.writeFile(pathname, JSON.stringify(auth), function (err) {
if (err) {
console.error('[ERROR] in writing auth details');
console.error(err);
@@ -72,36 +140,38 @@ function sendMail(state, auth) {
module.exports.pairRequest = function (opts) {
console.log("It's auth'n time!");
var state = opts.state;
- var auth = opts.auth;
+ var authReq = opts.auth;
var jwt = require('jsonwebtoken');
+ var auth;
- auth.id = crypto.randomBytes(12).toString('hex');
- auth.secret = crypto.randomBytes(12).toString('hex');
- //var id = crypto.randomBytes(16).toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');
+ authReq.id = crypto.randomBytes(12).toString('hex');
+ authReq.secret = crypto.randomBytes(12).toString('hex');
- console.log("[DEBUG] !!state", !!state);
- console.log("[DEBUG] !!auth", !!auth);
- return sendMail(state, auth).then(function () {
+ return sendMail(state, authReq).then(function () {
var now = Date.now();
- var authnToken = {
+ var pin = (authReq.otp || '').toString().replace(/\s\+/g, '') || Auths._no_pin;
+ var authnData = {
domains: []
, ports: []
, aud: state.config.webminDomain
- , iss: Math.round(now / 1000)
- , id: auth.id
- , pin: auth.otp
- , hostname: auth.hostname
+ , iat: Math.round(now / 1000)
+ , id: authReq.id
+ , pin: pin
+ , hostname: authReq.hostname
};
- _auths[auth.id] = _auths[auth.secret] = {
- dt: now
- , authn: jwt.sign(authnToken, state.secret)
- , pin: auth.otp
- , id: auth.id
- , secret: auth.secret
+ auth = {
+ id: authReq.id
+ , secret: authReq.secret
+ , pin: pin
+ , dt: now
+ , exp: now + (2 * 60 * 60 * 1000)
+ , authnData: authnData
+ , authn: jwt.sign(authnData, state.secret)
+ , request: authReq
};
- authnToken.jwt = _auths[auth.id].authn;
- // return empty token which will receive grants upon authorization
- return authnToken;
+ authnData.jwt = auth.authn;
+ Auths.set(auth, authReq.id, authReq.secret);
+ return authnData;
});
};
module.exports.pairPin = function (opts) {
@@ -109,96 +179,115 @@ module.exports.pairPin = function (opts) {
return state.Promise.resolve().then(function () {
var pin = opts.pin;
var secret = opts.secret;
- var auth = _auths[secret];
+ var auth = Auths.getBySecretAndPin(secret, pin);
- if (!auth || auth.secret !== opts.secret) {
- throw new Error("I can't even right now - bad magic link id");
+ if (!auth) {
+ throw new Error("I can't even right now - bad magic link or pairing code");
}
- // XXX security, we want to check the pin if it's supported serverside,
- // regardless of what the client sends. This bad logic is just for testing.
- if (pin && auth.pin && pin !== auth.pin) {
- throw new Error("I can't even right now - bad device pair pin");
+ if (auth._offered) {
+ return auth._offered;
}
- auth._paired = true;
- //delete _auths[auth.id];
var hri = require('human-readable-ids').hri;
var hrname = hri.random() + '.' + state.config.sharedDomain;
- var authzToken = {
- domains: [ hrname ]
- , ports: [ (1024 + 1) + Math.round(Math.random() * 6300) ]
+ // 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
- , iss: Math.round(Date.now() / 1000)
- , id: auth.id
+ , iat: Math.round(Date.now() / 1000)
, hostname: auth.hostname
};
- authzToken.jwt = jwt.sign(authzToken, state.secret);
- fs.writeFile(path.join(__dirname, 'emails', auth.subject + '.data'), JSON.stringify(authzToken), function (err) {
+ var pathname = path.join(__dirname, 'emails', auth.subject + '.' + hrname + '.data');
+ auth.authz = jwt.sign(authzData, state.secret);
+ authzData.jwt = auth.authz;
+ fs.writeFile(pathname, JSON.stringify(authzData), function (err) {
if (err) {
console.error('[ERROR] in writing token details');
console.error(err);
}
});
- return authzToken;
+ auth._offered = authzData;
+ return authzData;
});
};
-module.exports.pairState = function (opts) {
- var state = opts.state;
- var auth = opts.auth;
- var resolve = opts.resolve;
- var reject = opts.reject;
-
- // TODO use global interval whenever the number of active links is high
- var t = setTimeout(function () {
- console.log("[Magic Link] Timeout for '" + auth.subject + "'");
- delete _auths[auth.id];
- var err = new Error("Login Failure: Magic Link was not clicked within 5 minutes");
- err.code = 'E_LOGIN_TIMEOUT';
- reject();
- }, 2 * 60 * 60 * 1000);
-
- function authorize(pin) {
- console.log("mighty auth'n ranger!");
- clearTimeout(t);
- return module.exports.pairPin({ secret: auth.secret, pin: pin }).then(function (tokenData) {
- // TODO call state object with socket info rather than resolve
- resolve(tokenData);
- return tokenData;
- }, function (err) {
- reject(err);
- return state.Promise.reject(err);
- });
- }
-
- _auths[auth.id].resolve = authorize;
- _auths[auth.id].reject = reject;
-};
+// From a WS connection
module.exports.authenticate = function (opts) {
var jwt = require('jsonwebtoken');
var jwtoken = opts.auth;
- var auth = opts.auth;
+ var authReq = opts.auth;
var state = opts.state;
+ var auth;
+ var decoded;
- if ('object' === typeof auth && /^.+@.+\..+$/.test(auth.subject)) {
- return module.exports.pairRequest(opts).then(function () {
- return new state.Promise(function (resolve, reject) {
- opts.resolve = resolve;
- opts.reject = reject;
- module.exports.pairState(opts);
- });
+ function getPromise(auth) {
+ if (auth.promise) { return auth.promise; }
+
+ auth.promise = new state.Promise(function (resolve, reject) {
+
+ // Resolve
+ // this should resolve when the magic link is clicked in the email
+ // and the pair code is entered in successfully
+
+ // Reject
+ // this should reject when the pair code is entered incorrectly
+ // multiple times (or something else goes wrong)
+ // this will cause the websocket to disconnect
+
+ auth.resolve = resolve;
+ auth.reject = reject;
+ });
+
+ return auth.promise;
+ }
+
+ if ('object' === typeof authReq && /^.+@.+\..+$/.test(authReq.subject)) {
+ console.log("[ext token] Looks Like Auth Object");
+ return module.exports.pairRequest(opts).then(function (authnData) {
+ console.log("[ext token] Promises Like Auth Object");
+ var auth = Auths.get(authnData.id);
+ return getPromise(auth);
});
}
- console.log("just trying a normal token...");
- var decoded;
+ console.log("[ext token] Trying Token Parse");
try {
decoded = jwt.decode(jwtoken, { complete: true });
+ auth = Auths.get(decoded.payload.id);
} catch(e) {
+ console.log("[ext token] Token Did Not Parse");
decoded = null;
}
+ console.log("[ext token] decoded auth token:");
+ console.log(decoded);
+
+ if (!auth) {
+ console.log("[ext token] did not find auth object");
+ }
+
+ // TODO technically this could leak the token through a timing attack
+ // but it would require already knowing the semi-secret id and having
+ // completed the pair code
+ if (auth && (auth.authn === jwtoken || auth.authz === jwtoken)) {
+ if (!auth.authz) {
+ console.log("[ext token] Promise Authz");
+ return getPromise(auth);
+ }
+
+ console.log("[ext token] Use Available Authz");
+ // If they used authn but now authz is available, use authz
+ // (i.e. connects, but no domains or ports)
+ opts.auth = auth.authz;
+ // The browser may poll for this value
+ // otherwise we could also remove the auth at this time
+ auth._claimed = true;
+ }
+
+ console.log("[ext token] Continue With Auth Token");
return state.defaults.authenticate(opts.auth);
};
@@ -224,7 +313,8 @@ app.use('/api', function (req, res, next) {
});
});
app.use('/api', bodyParser.json());
-// From Device
+
+// From Device (which knows id, but not secret)
app.post('/api/telebit.cloud/pair_request', function (req, res) {
var auth = req.body;
console.log('[ext] pair_request (request)', req.headers);
@@ -242,57 +332,100 @@ app.post('/api/telebit.cloud/pair_request', function (req, res) {
res.send({ error: { code: err.code, message: err.toString() } });
});
});
-// From Browser
-app.post('/api/telebit.cloud/pair_code', function (req, res) {
- var auth = req.body;
- return module.exports.pairPin({ secret: auth.magic, pin: auth.pin }).then(function (tokenData) {
+
+// From Browser (which knows secret, but not pin)
+app.get('/api/telebit.cloud/pair_request/:secret', function (req, res) {
+ var secret = req.params.secret;
+ var auth = Auths.getBySecret(secret);
+ var crypto = require('crypto');
+ var response = {};
+
+
+ if (!auth) {
+ res.send({ error: { message: "Invalid" } });
+ return;
+ }
+
+ auth.referer = req.headers.referer;
+ auth.user_agent = req.headers['user-agent'];
+
+ response.id = auth.id;
+ // do not reveal email or otp
+ [ 'scope', 'hostname', 'os_type', 'os_platform', 'os_release', 'os_arch' ].forEach(function (key) {
+ response[key] = auth.request[key];
+ });
+ res.send(response);
+});
+
+// From User (which has entered pin)
+function pairCode(req, res) {
+ console.log("DEBUG telebit.cloud magic");
+ console.log(req.body || req.params);
+
+ var magic;
+ var pin;
+
+ if (req.body) {
+ magic = req.body.magic;
+ pin = req.body.pin;
+ } else {
+ magic = req.params.magic || req.query.magic;
+ pin = req.params.pin || req.query.pin;
+ }
+
+ return module.exports.pairPin({
+ state: req._state
+ , secret: magic
+ , pin: pin
+ }).then(function (tokenData) {
res.send(tokenData);
}, function (err) {
- res.send({ error: err });
+ res.send({ error: { message: err.toString() } });
+ //res.send(tokenData || { error: { code: "E_TOKEN", message: "Invalid or expired magic link. (" + magic + ")" } });
});
-});
-// From Device (polling)
+}
+app.post('/api/telebit.cloud/pair_code', pairCode);
+// Alternate From User (TODO remove in favor of the above)
+app.get('/api/telebit.cloud/magic/:magic/:pin?', pairCode);
+
+// From Device and Browser (polling)
app.get(urls.pairState, function (req, res) {
// check if pair is complete
// respond immediately if so
// wait for a little bit otherwise
// respond if/when it completes
// or respond after time if it does not complete
- var auth = _auths[req.params.id];
+ var auth = Auths.get(req.params.id); // id or secret accepted
if (!auth) {
res.send({ status: 'invalid' });
return;
}
- if (true === auth.paired) {
- res.send({
- status: 'ready', access_token: _auths[req.params.id].jwt
- , grant: { domains: auth.domains || [], ports: auth.ports || [] }
- });
- } else if (false === _auths[req.params.id].paired) {
- res.send({ status: 'failed', error: { message: "device pairing failed" } });
- } else {
- res.send({ status: 'pending' });
- }
-});
-// From Browser
-app.get('/api/telebit.cloud/magic/:magic/:pin?', function (req, res) {
- console.log("DEBUG telebit.cloud magic");
- var tokenData;
- var magic = req.params.magic || req.query.magic;
- var pin = req.params.pin || req.query.pin;
- console.log("DEBUG telebit.cloud magic 1a", magic);
- if (_auths[magic] && magic === _auths[magic].secret) {
- console.log("DEBUG telebit.cloud magic 1b");
- tokenData = _auths[magic].resolve(pin);
- console.log("DEBUG telebit.cloud magic 1c");
- res.send(tokenData);
- } else {
- console.log("DEBUG telebit.cloud magic 2");
- res.send({ error: { code: "E_TOKEN", message: "Invalid or expired magic link. (" + magic + ")" } });
- console.log("DEBUG telebit.cloud magic 2b");
+ function check(i) {
+ if (auth._claimed) {
+ res.send({
+ status: 'complete'
+ });
+ } else if (auth._offered) {
+ res.send({
+ status: 'ready', access_token: auth.authz
+ , grant: { domains: auth.domains || [], ports: auth.ports || [] }
+ });
+ } else if (false === auth._offered) {
+ res.send({ status: 'failed', error: { message: "device pairing failed" } });
+ } else if (i >= 5) {
+ var stateUrl = 'https://' + req._state.config.apiDomain + urls.pairState.replace(/:id/g, auth.id);
+ res.statusCode = 200;
+ res.setHeader('Location', stateUrl);
+ res.setHeader('Link', '<' + stateUrl + '>;rel="next"');
+ res.send({ status: 'pending' });
+ } else {
+ setTimeout(check, 3 * 1000, i + 1);
+ }
}
+ check(0);
});
+
module.exports.webadmin = function (state, req, res) {
//if (!loaded) { loaded = true; app.use('/', state.defaults.webadmin); }
console.log('[DEBUG] extensions webadmin');