From 5cd306a18a42ce4137c836a17adbb460d95be079 Mon Sep 17 00:00:00 2001 From: ansuz Date: Sat, 6 May 2023 14:39:23 +0530 Subject: [PATCH] add client-side support for TOTP-authenticated sessions --- customize.dist/login.js | 132 ++++++++++++++++++++++++++----- www/common/common-constants.js | 2 + www/common/common-util.js | 29 +++++++ www/common/cryptpad-common.js | 75 +++++++++++++----- www/common/outer/http-command.js | 100 +++++++++++++++++++++++ www/common/outer/local-store.js | 9 +++ 6 files changed, 310 insertions(+), 37 deletions(-) create mode 100644 www/common/outer/http-command.js diff --git a/customize.dist/login.js b/customize.dist/login.js index 15707ca94..18463916e 100644 --- a/customize.dist/login.js +++ b/customize.dist/login.js @@ -15,11 +15,12 @@ define([ '/bower_components/nthen/index.js', '/common/outer/login-block.js', '/common/common-hash.js', + '/common/outer/http-command.js', '/bower_components/tweetnacl/nacl-fast.min.js', '/bower_components/scrypt-async/scrypt-async.min.js', // better load speed ], function ($, Listmap, Crypto, Util, NetConfig, Cred, ChainPad, Realtime, Constants, UI, - Feedback, LocalStore, Messages, nThen, Block, Hash) { + Feedback, LocalStore, Messages, nThen, Block, Hash, ServerCommand) { var Exports = { Cred: Cred, Block: Block, @@ -172,26 +173,117 @@ define([ // determine where a block for your set of keys would be stored blockUrl = Block.getBlockUrl(res.opt.blockKeys); - // Check whether there is a block at that location - Util.fetch(blockUrl, waitFor(function (err, block) { - // if users try to log in or register, we must check - // whether there is a block. - - // the block is only useful if it can be decrypted, though - if (err) { - console.log("no block found"); - return; + var TOTP_prompt = function (cb) { + // XXX This should use nice UI elements integrated into + // the loading screen. window.prompt is here for prototyping only + var code = window.prompt('Enter TOTP'); + if (!code) { + return void cb("INVALID_TOTP"); } + ServerCommand(res.opt.blockKeys.sign, { + command: 'TOTP_VALIDATE', + code: code, + // TODO optionally allow the user to specify a lifetime for this session? + // this will require a little bit of server work + // and more UI/UX: + // ie. just a simple "remember me" checkbox? + // allow them to specify a lifetime for the session? + // "log me out after one day"? + }, cb); + }; - var decryptedBlock = Block.decrypt(block, blockKeys); - if (!decryptedBlock) { - console.error("Found a login block but failed to decrypt"); - return; - } + var done = waitFor(); + var responseToDecryptedBlock = function (response, cb) { + response.arrayBuffer().then(arraybuffer => { + arraybuffer = new Uint8Array(arraybuffer); + var decryptedBlock = Block.decrypt(arraybuffer, blockKeys); + if (!decryptedBlock) { + console.error("BLOCK DECRYPTION ERROR"); + return void cb("BLOCK_DECRYPTION_ERROR"); + } + cb(void 0, decryptedBlock); + }); + }; - //console.error(decryptedBlock); - res.blockInfo = decryptedBlock; - })); + var TOTP_response; + nThen(function (w) { + Util.getBlock(blockUrl, { + // request the block without credentials + }, w(function (err, response) { + if (err === 401) { + return void console.log("Block requires 2FA"); + } + + // Some other error? + if (err) { + console.error(err); + w.abort(); + return void done(); + } + + // If the block was returned without requiring authentication + // then we can abort the subsequent steps of this nested nThen + w.abort(); + + // decrypt the response and continue the normal procedure with its payload + responseToDecryptedBlock(response, function (err, decryptedBlock) { + if (err) { + // if a block was present but you were not able to decrypt it... + console.error(err); + waitFor.abort(); + return void cb(err); + } + res.blockInfo = decryptedBlock; + done(); + }); + })); + }).nThen(function (w) { + // if you're here then you need to request a JWT + var done = w(); + var tries = 3; + var ask = function () { + if (!tries) { + w.abort(); + waitFor.abort(); + return void cb('TOTP_ATTEMPTS_EXHAUSTED'); + } + tries--; + TOTP_prompt(function (err, response) { + // ask again until your number of tries are exhausted + if (err) { + console.error(err); + console.log("Normal failure. Asking again...") + return void ask(); + } + if (!response || !response.bearer) { + console.log(response); + console.log("Unexpected failure. No bearer token. Asking again"); + return void ask(); + } + console.log("Successfully retrieved a bearer token"); + res.TOTP_token = TOTP_response = response; + done(); + }); + }; + ask(); + }).nThen(function (w) { + Util.getBlock(blockUrl, TOTP_response, function (err, response) { + if (err) { + w.abort(); + console.error(err); + return void cb('BLOCK_ERROR_3'); + } + + responseToDecryptedBlock(response, function (err, decryptedBlock) { + if (err) { + waitFor.abort(); + return void cb(err); + } + res.blockInfo = decryptedBlock; + done(); + }); + }); + }); }).nThen(function (waitFor) { // we assume that if there is a block, it was created in a valid manner // so, just proceed to the next block which handles that stuff @@ -357,6 +449,10 @@ define([ if (l) { localStorage.setItem(LS_LANG, l); } + + if (res.TOTP_token && res.TOTP_token.bearer) { + LocalStore.setSessionToken(res.TOTP_token.bearer); + } return void LocalStore.login(userHash, uname, function () { cb(void 0, res); }); diff --git a/www/common/common-constants.js b/www/common/common-constants.js index 6be5d7552..888edeafd 100644 --- a/www/common/common-constants.js +++ b/www/common/common-constants.js @@ -5,6 +5,8 @@ define(['/customize/application_config.js'], function (AppConfig) { userNameKey: 'User_name', blockHashKey: 'Block_hash', fileHashKey: 'FS_hash', + sessionJWT: 'Session_JWT', + // Store displayNameKey: 'cryptpad.username', oldStorageKey: 'CryptPad_RECENTPADS', diff --git a/www/common/common-util.js b/www/common/common-util.js index 678806f98..8b730fa60 100644 --- a/www/common/common-util.js +++ b/www/common/common-util.js @@ -312,6 +312,35 @@ if (!/^[a-f0-9]{48}$/.test(cacheKey)) { cacheKey = undefined; } return cacheKey; }; + + + Util.getBlock = function (src, opt, cb) { + var CB = Util.once(Util.mkAsync(cb)); + + var headers = {}; + + if (typeof(opt.bearer) === 'string' && opt.bearer) { + headers.authorization = `Bearer ${opt.bearer}`; + } + + + fetch(src, { + method: 'GET', + credentials: 'include', + headers: headers, + }).then(response => { + if (response.ok) { + // TODO this should probably be returned as an arraybuffer or something rather than a promise + // this is resulting in some code duplication + return void CB(void 0, response); + } + CB(response.status); + }).catch(error => { + CB(error); + }); + }; + + Util.fetch = function (src, cb, progress, cache) { var CB = Util.once(Util.mkAsync(cb)); diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index 9911a6e72..3edc4b8a9 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -2483,6 +2483,7 @@ define([ } }).nThen(function (waitFor) { + // if a block URL is present then the user is probably logged in with a modern account var blockHash = LocalStore.getBlockHash(); if (blockHash) { console.debug("Block hash is present"); @@ -2492,28 +2493,64 @@ define([ console.error("Failed to parse blockHash"); console.log(parsed); return; - } else { - //console.log(parsed); } - Util.fetch(parsed.href, waitFor(function (err, arraybuffer) { - if (err) { return void console.log(err); } - // use the results to load your user hash and - // put your userhash into localStorage - try { - var block_info = Block.decrypt(arraybuffer, parsed.keys); - if (!block_info) { - console.error("Failed to decrypt !"); - return; - } - userHash = block_info[Constants.userHashKey]; - if (!userHash || userHash !== LocalStore.getUserHash()) { - return void requestLogin(); - } - } catch (e) { - console.error(e); - return void console.error("failed to decrypt or decode block content"); + // they might also have a "session token", which is a JWT. + // this indicates that their login block is protected with 2FA + var sessionToken = LocalStore.getSessionToken() || undefined; + + var done = waitFor(); + + // request the login block, providing credentials if available + Util.getBlock(parsed.href, { + bearer: sessionToken, + }, waitFor((err, response) => { + if (err === 401) { + // a 401 error indicates insufficient authentication + // either their JWT is invalid, or they didn't provide one + // when it was expected. Log them out and redirect them to + // the login page, where they will be able to authenticate + // and request a new JWT + waitFor.abort(); + return void LocalStore.logout(function () { + requestLogin(); + }); } + + if (err) { + // TODO + // it seems wrong that errors here aren't reported or handled + // but it's consistent with other failure cases in the rest of this process + // that probably justifies some more thorough review. + // In particular, it should not be possible to be "half-logged-in" + // behaving like a guest after trying to authenticate as a registered user + return void console.error(err); + } + + // if no errors occurred then we can try to convert the response + // to an arraybuffer and decrypt its payload + response.arrayBuffer().then(arraybuffer => { + arraybuffer = new Uint8Array(arraybuffer); + // use the results to load your user hash and + // put your userhash into localStorage + try { + var block_info = Block.decrypt(arraybuffer, parsed.keys); + if (!block_info) { + console.error("Failed to decrypt !"); + return; + } + userHash = block_info[Constants.userHashKey]; + if (!userHash || userHash !== LocalStore.getUserHash()) { + return void LocalStore.logout(function () { + requestLogin(); + }); + } + } catch (e) { + console.error(e); + return void console.error("failed to decrypt or decode block content"); + } + done(); + }); })); } }).nThen(function (waitFor) { diff --git a/www/common/outer/http-command.js b/www/common/outer/http-command.js new file mode 100644 index 000000000..653fbb46d --- /dev/null +++ b/www/common/outer/http-command.js @@ -0,0 +1,100 @@ +define([ + '/bower_components/nthen/index.js', + '/common/common-util.js', + '/api/config', + + '/bower_components/tweetnacl/nacl-fast.min.js', +], function (nThen, Util, ApiConfig) { + var Nacl = window.nacl; + var clone = o => JSON.parse(JSON.stringify(o)); + var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)); + var postData = function (url, data, cb) { + var CB = Util.once(Util.mkAsync(cb)); + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }).then(response => { + if (response.ok) { + return void response.json().then(result => { CB(void 0, result); }); + } + + response.json().then().then(result => { + CB(response.status, result); + }); + //CB(response.status, response); + }).catch(error => { + CB(error); + }); + }; + + var API_ORIGIN = (function () { + var url; + var unsafeOriginURL = new URL(ApiConfig.httpUnsafeOrigin); + try { + url = new URL(ApiConfig.websocketPath, ApiConfig.httpUnsafeOrigin); + url.protocol = unsafeOriginURL.protocol; + return url.origin; + } catch (err) { + console.error(err); + return ApiConfig.httpUnsafeOrigin; + } + }()); + var serverCommand = function (keypair, my_data, cb) { + var obj = clone(my_data); + obj.publicKey = Nacl.util.encodeBase64(keypair.publicKey); + obj.nonce = randomToken(); + var href = new URL('/api/auth/', API_ORIGIN); + var txid, date; + + var responseBody; + + nThen(function (w) { + // Tell the server we want to do some action + postData(href, obj, w((err, data) => { + if (err) { + w.abort(); + console.error(err); + // there might be more info here + if (data) { console.error(data); } + return void cb(err); + } + + // if the requested action is valid, it responds with a txid and a nonce + // bundle all that up into an object, stringify it, and sign it. + // respond with an object: {sig, txid} + if (!data.date || !data.txid) { + w.abort(); + return void cb('REQUEST_REJECTED'); + } + txid = data.txid; + date = data.date; + })); + }).nThen(function (w) { + var copy = clone(obj); + copy.txid = txid; + copy.date = date; + var toSign = Nacl.util.decodeUTF8(JSON.stringify(copy)); + var sig = Nacl.sign.detached(toSign, keypair.secretKey); + var encoded = Nacl.util.encodeBase64(sig); + var obj2 = { + sig: encoded, + txid: txid, + }; + postData(href, obj2, w((err, data) => { + if (err) { + w.abort(); + console.err(err); + // there might be more info here + if (data) { console.error(data); } + return void cb("RESPONSE_REJECTED"); + } + cb(void 0, data); + })); + }); + }; + + return serverCommand; +}); diff --git a/www/common/outer/local-store.js b/www/common/outer/local-store.js index 6f5f128ba..c7fa13a88 100644 --- a/www/common/outer/local-store.js +++ b/www/common/outer/local-store.js @@ -76,6 +76,14 @@ define([ safeSet(Constants.blockHashKey, hash); }; + LocalStore.getSessionToken = function () { + return localStorage[Constants.sessionJWT]; + }; + + LocalStore.setSessionToken = function (token) { + safeSet(Constants.sessionJWT, token); + }; + LocalStore.getAccountName = function () { return localStorage[Constants.userNameKey]; }; @@ -121,6 +129,7 @@ define([ Constants.userNameKey, Constants.userHashKey, Constants.blockHashKey, + Constants.sessionJWT, 'loginToken', 'plan', ].forEach(function (k) {