add client-side support for TOTP-authenticated sessions

This commit is contained in:
ansuz 2023-05-06 14:39:23 +05:30
parent 6ef1527a29
commit 5cd306a18a
6 changed files with 310 additions and 37 deletions

View File

@ -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);
});

View File

@ -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',

View File

@ -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));

View File

@ -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) {

View File

@ -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;
});

View File

@ -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) {