handle pairing request via API

This commit is contained in:
AJ ONeal 2018-06-21 06:13:05 +00:00
parent 179256a88e
commit 148cda8516
3 changed files with 392 additions and 135 deletions

View File

@ -13,14 +13,49 @@
</div> </div>
</div> </div>
<div class="js-magic" hidden> <div class="js-magic" hidden><form class="js-submit">
<h1>Give us about 30 seconds...</h1> <h1>Telebit</h1>
We're initializing our connection, redirecting you to your device at <h2>Pair <span class="js-hostname">Device</span></h1>
<a class="js-new-href">{{js-new-href}}</a>
which will then take a few seconds to initialize as it gets your https certificates for peer-to-peer, end-to-end encryption <p>Enter your device pairing code:
<br> <input type="text" name="pair-code" placeholder="ex: 000 000">
<br> </p>
<small><pre><code class="js-token-data">{{js-token-data}}</code></pre></small>
<ul>
<li><label><input name="telebit-agree" type="checkbox" required> Agree to Telebit Terms of Service</label>
</li>
<li><label><input name="letsencrypt-agree" type="checkbox" required> Agree to Let's Encrypt Terms of Service</label>
</li>
</ul>
<p>
<button type="submit">Claim Device</button>
</p>
</form></div>
<div class="js-authz" hidden>
<h1>Telebit Authorized</h1>
<h2>Waiting for your device to connect...</h2>
<p>Check your device to complete the pairing.</p>
<h2>🔒 <span class="js-domainname">xxx-xxx-xxx.example.com</span></h2>
<p>When your device is paired you will be redirected to
<a class="js-new-href">{{js-new-href}}</a>.
</p>
<h2 class="js-serviceport">xxxxx</h2>
<p>When your device is paired you will be able to use <span class="js-serviceport">xxxxx</span>
for SSH, and other TCP protocols.</p>
<pre><code>telebit ssh auto
ssh <span class="js-domainname">{{servername}}</span> -p <span class="js-serviceport">{{serviceport}}</span></code></pre>
</code></pre>
<h2>Authorization Token</h2>
<small><pre><code class="js-token">{{js-token}}</code></pre></small>
</div> </div>
<script src="js/app.js"></script> <script src="js/app.js"></script>

View File

@ -1,29 +1,118 @@
(function () { (function () {
'use strict'; 'use strict';
var magic = (window.location.hash || '').substr(2).replace(/magic=/, ''); var meta = {};
var magic;
if (magic) { function checkStatus() {
window.fetch('https://api.' + location.hostname + '/api/telebit.cloud/magic/' + magic, { // TODO use Location or Link
window.fetch(meta.baseUrl + 'api/telebit.cloud/pair_state/' + magic, {
method: 'GET' method: 'GET'
, cors: true , cors: true
}).then(function (resp) { }).then(function (resp) {
return resp.json().then(function (json) { return resp.json().then(function (data) {
if (json.error) { 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-error').hidden = false;
document.querySelector('.js-magic-link').innerText = magic; document.querySelector('.js-magic-link').innerText = magic;
return; return;
} }
document.querySelector('body').hidden = false; document.querySelector('body').hidden = false;
document.querySelector('.js-magic').hidden = false; document.querySelector('.js-magic').hidden = false;
document.querySelector('.js-token-data').innerText = JSON.stringify(json, null, 2); document.querySelector('.js-hostname').innerText = data.hostname || 'Device';
document.querySelector('.js-new-href').href = json.domains[0]; //document.querySelector('.js-token-data').innerText = JSON.stringify(data, null, 2);
document.querySelector('.js-new-href').innerText = json.domains[0];
}); });
}); });
} else {
document.querySelector('body').hidden = false; document.querySelector('.js-submit').addEventListener('submit', function (ev) {
document.querySelector('.js-error').hidden = false; 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();
});
});
}()); }());

View File

@ -9,6 +9,73 @@ var jwt = require('jsonwebtoken');
var requestAsync = util.promisify(require('request')); var requestAsync = util.promisify(require('request'));
var _auths = module.exports._auths = {}; 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) { function sendMail(state, auth) {
console.log('[DEBUG] ext auth', auth); console.log('[DEBUG] ext auth', auth);
@ -58,7 +125,8 @@ function sendMail(state, auth) {
, html: html , html: html
} }
}).then(function (resp) { }).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) { if (err) {
console.error('[ERROR] in writing auth details'); console.error('[ERROR] in writing auth details');
console.error(err); console.error(err);
@ -72,36 +140,38 @@ function sendMail(state, auth) {
module.exports.pairRequest = function (opts) { module.exports.pairRequest = function (opts) {
console.log("It's auth'n time!"); console.log("It's auth'n time!");
var state = opts.state; var state = opts.state;
var auth = opts.auth; var authReq = opts.auth;
var jwt = require('jsonwebtoken'); var jwt = require('jsonwebtoken');
var auth;
auth.id = crypto.randomBytes(12).toString('hex'); authReq.id = crypto.randomBytes(12).toString('hex');
auth.secret = crypto.randomBytes(12).toString('hex'); authReq.secret = crypto.randomBytes(12).toString('hex');
//var id = crypto.randomBytes(16).toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');
console.log("[DEBUG] !!state", !!state); return sendMail(state, authReq).then(function () {
console.log("[DEBUG] !!auth", !!auth);
return sendMail(state, auth).then(function () {
var now = Date.now(); var now = Date.now();
var authnToken = { var pin = (authReq.otp || '').toString().replace(/\s\+/g, '') || Auths._no_pin;
var authnData = {
domains: [] domains: []
, ports: [] , ports: []
, aud: state.config.webminDomain , aud: state.config.webminDomain
, iss: Math.round(now / 1000) , iat: Math.round(now / 1000)
, id: auth.id , id: authReq.id
, pin: auth.otp , pin: pin
, hostname: auth.hostname , hostname: authReq.hostname
}; };
_auths[auth.id] = _auths[auth.secret] = { auth = {
dt: now id: authReq.id
, authn: jwt.sign(authnToken, state.secret) , secret: authReq.secret
, pin: auth.otp , pin: pin
, id: auth.id , dt: now
, secret: auth.secret , exp: now + (2 * 60 * 60 * 1000)
, authnData: authnData
, authn: jwt.sign(authnData, state.secret)
, request: authReq
}; };
authnToken.jwt = _auths[auth.id].authn; authnData.jwt = auth.authn;
// return empty token which will receive grants upon authorization Auths.set(auth, authReq.id, authReq.secret);
return authnToken; return authnData;
}); });
}; };
module.exports.pairPin = function (opts) { module.exports.pairPin = function (opts) {
@ -109,96 +179,115 @@ module.exports.pairPin = function (opts) {
return state.Promise.resolve().then(function () { return state.Promise.resolve().then(function () {
var pin = opts.pin; var pin = opts.pin;
var secret = opts.secret; var secret = opts.secret;
var auth = _auths[secret]; var auth = Auths.getBySecretAndPin(secret, pin);
if (!auth || auth.secret !== opts.secret) { if (!auth) {
throw new Error("I can't even right now - bad magic link id"); 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, if (auth._offered) {
// regardless of what the client sends. This bad logic is just for testing. return auth._offered;
if (pin && auth.pin && pin !== auth.pin) {
throw new Error("I can't even right now - bad device pair pin");
} }
auth._paired = true;
//delete _auths[auth.id];
var hri = require('human-readable-ids').hri; var hri = require('human-readable-ids').hri;
var hrname = hri.random() + '.' + state.config.sharedDomain; var hrname = hri.random() + '.' + state.config.sharedDomain;
var authzToken = { // TODO check used / unused names and ports
domains: [ hrname ] var authzData = {
, ports: [ (1024 + 1) + Math.round(Math.random() * 6300) ] id: auth.id
, domains: [ hrname ]
, ports: [ (1024 + 1) + Math.round(Math.random() * 65535) ]
, aud: state.config.webminDomain , aud: state.config.webminDomain
, iss: Math.round(Date.now() / 1000) , iat: Math.round(Date.now() / 1000)
, id: auth.id
, hostname: auth.hostname , hostname: auth.hostname
}; };
authzToken.jwt = jwt.sign(authzToken, state.secret); var pathname = path.join(__dirname, 'emails', auth.subject + '.' + hrname + '.data');
fs.writeFile(path.join(__dirname, 'emails', auth.subject + '.data'), JSON.stringify(authzToken), function (err) { auth.authz = jwt.sign(authzData, state.secret);
authzData.jwt = auth.authz;
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 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) { module.exports.authenticate = function (opts) {
var jwt = require('jsonwebtoken'); var jwt = require('jsonwebtoken');
var jwtoken = opts.auth; var jwtoken = opts.auth;
var auth = opts.auth; var authReq = opts.auth;
var state = opts.state; var state = opts.state;
var auth;
var decoded;
if ('object' === typeof auth && /^.+@.+\..+$/.test(auth.subject)) { function getPromise(auth) {
return module.exports.pairRequest(opts).then(function () { if (auth.promise) { return auth.promise; }
return new state.Promise(function (resolve, reject) {
opts.resolve = resolve; auth.promise = new state.Promise(function (resolve, reject) {
opts.reject = reject;
module.exports.pairState(opts); // 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..."); console.log("[ext token] Trying Token Parse");
var decoded;
try { try {
decoded = jwt.decode(jwtoken, { complete: true }); decoded = jwt.decode(jwtoken, { complete: true });
auth = Auths.get(decoded.payload.id);
} catch(e) { } catch(e) {
console.log("[ext token] Token Did Not Parse");
decoded = null; 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); return state.defaults.authenticate(opts.auth);
}; };
@ -224,7 +313,8 @@ app.use('/api', function (req, res, next) {
}); });
}); });
app.use('/api', bodyParser.json()); 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) { app.post('/api/telebit.cloud/pair_request', function (req, res) {
var auth = req.body; var auth = req.body;
console.log('[ext] pair_request (request)', req.headers); 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() } }); res.send({ error: { code: err.code, message: err.toString() } });
}); });
}); });
// From Browser
app.post('/api/telebit.cloud/pair_code', function (req, res) { // From Browser (which knows secret, but not pin)
var auth = req.body; app.get('/api/telebit.cloud/pair_request/:secret', function (req, res) {
return module.exports.pairPin({ secret: auth.magic, pin: auth.pin }).then(function (tokenData) { 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); res.send(tokenData);
}, function (err) { }, 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) { app.get(urls.pairState, function (req, res) {
// check if pair is complete // check if pair is complete
// respond immediately if so // respond immediately if so
// wait for a little bit otherwise // wait for a little bit otherwise
// respond if/when it completes // respond if/when it completes
// or respond after time if it does not complete // 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) { if (!auth) {
res.send({ status: 'invalid' }); res.send({ status: 'invalid' });
return; return;
} }
if (true === auth.paired) { function check(i) {
if (auth._claimed) {
res.send({ res.send({
status: 'ready', access_token: _auths[req.params.id].jwt status: 'complete'
});
} else if (auth._offered) {
res.send({
status: 'ready', access_token: auth.authz
, grant: { domains: auth.domains || [], ports: auth.ports || [] } , grant: { domains: auth.domains || [], ports: auth.ports || [] }
}); });
} else if (false === _auths[req.params.id].paired) { } else if (false === auth._offered) {
res.send({ status: 'failed', error: { message: "device pairing failed" } }); res.send({ status: 'failed', error: { message: "device pairing failed" } });
} else { } 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' }); 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 { } else {
console.log("DEBUG telebit.cloud magic 2"); setTimeout(check, 3 * 1000, i + 1);
res.send({ error: { code: "E_TOKEN", message: "Invalid or expired magic link. (" + magic + ")" } });
console.log("DEBUG telebit.cloud magic 2b");
} }
}
check(0);
}); });
module.exports.webadmin = function (state, req, res) { module.exports.webadmin = function (state, req, res) {
//if (!loaded) { loaded = true; app.use('/', state.defaults.webadmin); } //if (!loaded) { loaded = true; app.use('/', state.defaults.webadmin); }
console.log('[DEBUG] extensions webadmin'); console.log('[DEBUG] extensions webadmin');