mirror of
https://github.com/cryptpad/cryptpad.git
synced 2026-09-12 19:49:59 +05:00
SSO: OIDC login and register
This commit is contained in:
parent
0c94c1a602
commit
b93b5eae4e
@ -4,6 +4,7 @@ define([
|
||||
'/bower_components/chainpad-crypto/crypto.js',
|
||||
'/common/common-util.js',
|
||||
'/common/outer/network-config.js',
|
||||
'/common/common-login.js',
|
||||
'/common/common-credential.js',
|
||||
'/bower_components/chainpad/chainpad.dist.js',
|
||||
'/common/common-realtime.js',
|
||||
@ -19,14 +20,14 @@ define([
|
||||
|
||||
'/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,
|
||||
], function ($, Listmap, Crypto, Util, NetConfig, Login, Cred, ChainPad, Realtime, Constants, UI,
|
||||
Feedback, LocalStore, Messages, nThen, Block, Hash, ServerCommand) {
|
||||
var Exports = {
|
||||
Cred: Cred,
|
||||
Block: Block,
|
||||
// this is depended on by non-customizable files
|
||||
// be careful when modifying login.js
|
||||
requiredBytes: 192,
|
||||
requiredBytes: Login.requiredBytes,
|
||||
};
|
||||
|
||||
var Nacl = window.nacl;
|
||||
@ -39,11 +40,9 @@ define([
|
||||
redirectTo = newPad.href;
|
||||
}
|
||||
};
|
||||
if (window.location.hash) {
|
||||
setRedirectTo();
|
||||
}
|
||||
if (window.location.hash) { setRedirectTo(); }
|
||||
|
||||
Exports.ssoRegister = function (provider, cb) {
|
||||
Exports.ssoAuth = function (provider, cb) {
|
||||
var keys = Nacl.sign.keyPair();
|
||||
localStorage.CP_sso_auth = JSON.stringify({
|
||||
s: Nacl.util.encodeBase64(keys.secretKey),
|
||||
@ -55,518 +54,17 @@ define([
|
||||
register: true
|
||||
}, cb);
|
||||
};
|
||||
Exports.ssoRegisterCb = function () {
|
||||
var b64Keys = Util.tryParse(localStorage.CP_sso_auth);
|
||||
if (!b64Keys) {
|
||||
throw new Error("MISSING_SIGNATURE_KEYS");
|
||||
}
|
||||
var keys = {
|
||||
secretKey: Nacl.util.decodeBase64(b64Keys.s),
|
||||
publicKey: Nacl.util.decodeBase64(b64Keys.p)
|
||||
};
|
||||
ServerCommand(keys, {
|
||||
command: 'SSO_AUTH_CB',
|
||||
url: window.location.href
|
||||
}, function (err, data) {
|
||||
delete localStorage.CP_sso_auth;
|
||||
if (data && data.state) { window.location.href = '/register/'; }
|
||||
});
|
||||
};
|
||||
Exports.ssoLogin = function () {
|
||||
|
||||
};
|
||||
|
||||
var allocateBytes = Exports.allocateBytes = function (bytes) {
|
||||
var dispense = Cred.dispenser(bytes);
|
||||
Exports.allocateBytes = Login.allocateBytes;
|
||||
Exports.loadUserObject = Login.loadUserObject;
|
||||
|
||||
var opt = {};
|
||||
|
||||
// dispense 18 bytes of entropy for your encryption key
|
||||
var encryptionSeed = dispense(18);
|
||||
// 16 bytes for a deterministic channel key
|
||||
var channelSeed = dispense(16);
|
||||
// 32 bytes for a curve key
|
||||
var curveSeed = dispense(32);
|
||||
|
||||
var curvePair = Nacl.box.keyPair.fromSecretKey(new Uint8Array(curveSeed));
|
||||
opt.curvePrivate = Nacl.util.encodeBase64(curvePair.secretKey);
|
||||
opt.curvePublic = Nacl.util.encodeBase64(curvePair.publicKey);
|
||||
|
||||
// 32 more for a signing key
|
||||
var edSeed = opt.edSeed = dispense(32);
|
||||
|
||||
// 64 more bytes to seed an additional signing key
|
||||
var blockKeys = opt.blockKeys = Block.genkeys(new Uint8Array(dispense(64)));
|
||||
opt.blockHash = Block.getBlockHash(blockKeys);
|
||||
|
||||
// derive a private key from the ed seed
|
||||
var signingKeypair = Nacl.sign.keyPair.fromSeed(new Uint8Array(edSeed));
|
||||
|
||||
opt.edPrivate = Nacl.util.encodeBase64(signingKeypair.secretKey);
|
||||
opt.edPublic = Nacl.util.encodeBase64(signingKeypair.publicKey);
|
||||
|
||||
var keys = opt.keys = Crypto.createEditCryptor(null, encryptionSeed);
|
||||
|
||||
// 24 bytes of base64
|
||||
keys.editKeyStr = keys.editKeyStr.replace(/\//g, '-');
|
||||
|
||||
// 32 bytes of hex
|
||||
var channelHex = opt.channelHex = Util.uint8ArrayToHex(channelSeed);
|
||||
|
||||
// should never happen
|
||||
if (channelHex.length !== 32) { throw new Error('invalid channel id'); }
|
||||
|
||||
var channel64 = Util.hexToBase64(channelHex);
|
||||
|
||||
// we still generate a v1 hash because this function needs to deterministically
|
||||
// derive the same values as it always has. New accounts will generate their own
|
||||
// userHash values
|
||||
opt.userHash = '/1/edit/' + [channel64, opt.keys.editKeyStr].join('/') + '/';
|
||||
|
||||
return opt;
|
||||
var setMergeAnonDrive = function (value) {
|
||||
Exports.mergeAnonDrive = Boolean(value);
|
||||
};
|
||||
|
||||
|
||||
var loginOptionsFromBlock = Exports.loginOptionsFromBlock = function (blockInfo) {
|
||||
var opt = {};
|
||||
var parsed = Hash.getSecrets('pad', blockInfo.User_hash);
|
||||
opt.channelHex = parsed.channel;
|
||||
opt.keys = parsed.keys;
|
||||
opt.edPublic = blockInfo.edPublic;
|
||||
return opt;
|
||||
};
|
||||
|
||||
var loadUserObject = Exports.loadUserObject = function (opt, cb) {
|
||||
var config = {
|
||||
websocketURL: NetConfig.getWebsocketURL(),
|
||||
channel: opt.channelHex,
|
||||
data: {},
|
||||
validateKey: opt.keys.validateKey, // derived validation key
|
||||
crypto: Crypto.createEncryptor(opt.keys),
|
||||
logLevel: 1,
|
||||
classic: true,
|
||||
ChainPad: ChainPad,
|
||||
owners: [opt.edPublic]
|
||||
};
|
||||
|
||||
var rt = opt.rt = Listmap.create(config);
|
||||
rt.proxy
|
||||
.on('ready', function () {
|
||||
setTimeout(function () { cb(void 0, rt); });
|
||||
})
|
||||
.on('disconnect', function (info) {
|
||||
cb('E_DISCONNECT', info);
|
||||
});
|
||||
};
|
||||
|
||||
var isProxyEmpty = function (proxy) {
|
||||
var l = Object.keys(proxy).length;
|
||||
return l === 0 || (l === 2 && proxy._events && proxy.on);
|
||||
};
|
||||
|
||||
var setMergeAnonDrive = function () {
|
||||
Exports.mergeAnonDrive = 1;
|
||||
};
|
||||
|
||||
Exports.loginOrRegister = function (uname, passwd, isRegister, shouldImport, onOTP, cb) {
|
||||
if (typeof(cb) !== 'function') { return; }
|
||||
|
||||
// Usernames are all lowercase. No going back on this one
|
||||
uname = uname.toLowerCase();
|
||||
|
||||
// validate inputs
|
||||
if (!Cred.isValidUsername(uname)) { return void cb('INVAL_USER'); }
|
||||
if (!Cred.isValidPassword(passwd)) { return void cb('INVAL_PASS'); }
|
||||
if (isRegister && !Cred.isLongEnoughPassword(passwd)) {
|
||||
return void cb('PASS_TOO_SHORT');
|
||||
}
|
||||
|
||||
// results...
|
||||
var res = {
|
||||
register: isRegister,
|
||||
};
|
||||
|
||||
var RT, blockKeys, blockHash, blockUrl, Pinpad, rpc, userHash;
|
||||
|
||||
nThen(function (waitFor) {
|
||||
// derive a predefined number of bytes from the user's inputs,
|
||||
// and allocate them in a deterministic fashion
|
||||
Cred.deriveFromPassphrase(uname, passwd, Exports.requiredBytes, waitFor(function (bytes) {
|
||||
res.opt = allocateBytes(bytes);
|
||||
blockHash = res.opt.blockHash;
|
||||
blockKeys = res.opt.blockKeys;
|
||||
}));
|
||||
}).nThen(function (waitFor) {
|
||||
// the allocated bytes can be used either in a legacy fashion,
|
||||
// or in such a way that a previously unused byte range determines
|
||||
// the location of a layer of indirection which points users to
|
||||
// an encrypted block, from which they can recover the location of
|
||||
// the rest of their data
|
||||
|
||||
// determine where a block for your set of keys would be stored
|
||||
blockUrl = Block.getBlockUrl(res.opt.blockKeys);
|
||||
|
||||
var TOTP_prompt = function (err, cb) {
|
||||
onOTP(function (code) {
|
||||
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);
|
||||
}, false, err);
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
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(tries !== 2, 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
|
||||
if (res.blockInfo) { return; }
|
||||
|
||||
var opt = res.opt;
|
||||
|
||||
// load the user's object using the legacy credentials
|
||||
loadUserObject(opt, waitFor(function (err, rt) {
|
||||
if (err) {
|
||||
waitFor.abort();
|
||||
return void cb(err);
|
||||
}
|
||||
|
||||
// if a proxy is marked as deprecated, it is because someone had a non-owned drive
|
||||
// but changed their password, and couldn't delete their old data.
|
||||
// if they are here, they have entered their old credentials, so we should not
|
||||
// allow them to proceed. In time, their old drive should get deleted, since
|
||||
// it will should be pinned by anyone's drive.
|
||||
if (rt.proxy[Constants.deprecatedKey]) {
|
||||
waitFor.abort();
|
||||
return void cb('NO_SUCH_USER', res);
|
||||
}
|
||||
|
||||
if (isRegister && isProxyEmpty(rt.proxy)) {
|
||||
// If they are trying to register,
|
||||
// and the proxy is empty, then there is no 'legacy user' either
|
||||
// so we should just shut down this session and disconnect.
|
||||
//rt.network.disconnect();
|
||||
return; // proceed to the next async block
|
||||
}
|
||||
|
||||
// they tried to just log in but there's no such user
|
||||
// and since we're here at all there is no modern-block
|
||||
if (!isRegister && isProxyEmpty(rt.proxy)) {
|
||||
//rt.network.disconnect(); // clean up after yourself
|
||||
waitFor.abort();
|
||||
return void cb('NO_SUCH_USER', res);
|
||||
}
|
||||
|
||||
// they tried to register, but those exact credentials exist
|
||||
if (isRegister && !isProxyEmpty(rt.proxy)) {
|
||||
//rt.network.disconnect();
|
||||
waitFor.abort();
|
||||
Feedback.send('LOGIN', true);
|
||||
return void cb('ALREADY_REGISTERED', res);
|
||||
}
|
||||
|
||||
// if you are here, then there is no block, the user is trying
|
||||
// to log in. The proxy is **not** empty. All values assigned here
|
||||
// should have been deterministically created using their credentials
|
||||
// so setting them is just a precaution to keep things in good shape
|
||||
res.proxy = rt.proxy;
|
||||
res.realtime = rt.realtime;
|
||||
res.network = rt.network;
|
||||
|
||||
// they're registering...
|
||||
res.userHash = opt.userHash;
|
||||
res.userName = uname;
|
||||
|
||||
// export their signing key
|
||||
res.edPrivate = opt.edPrivate;
|
||||
res.edPublic = opt.edPublic;
|
||||
|
||||
// export their encryption key
|
||||
res.curvePrivate = opt.curvePrivate;
|
||||
res.curvePublic = opt.curvePublic;
|
||||
|
||||
if (shouldImport) { setMergeAnonDrive(); }
|
||||
|
||||
// don't proceed past this async block.
|
||||
waitFor.abort();
|
||||
|
||||
// We have to call whenRealtimeSyncs asynchronously here because in the current
|
||||
// version of listmap, onLocal calls `chainpad.contentUpdate(newValue)`
|
||||
// asynchronously.
|
||||
// The following setTimeout is here to make sure whenRealtimeSyncs is called after
|
||||
// `contentUpdate` so that we have an update userDoc in chainpad.
|
||||
setTimeout(function () {
|
||||
Realtime.whenRealtimeSyncs(rt.realtime, function () {
|
||||
// the following stages are there to initialize a new drive
|
||||
// if you are registering
|
||||
LocalStore.login(res.userHash, undefined, res.userName, function () {
|
||||
setTimeout(function () { cb(void 0, res); });
|
||||
});
|
||||
});
|
||||
});
|
||||
}));
|
||||
}).nThen(function (waitFor) { // MODERN REGISTRATION / LOGIN
|
||||
var opt;
|
||||
if (res.blockInfo) {
|
||||
opt = loginOptionsFromBlock(res.blockInfo);
|
||||
userHash = res.blockInfo.User_hash;
|
||||
//console.error(opt, userHash);
|
||||
} else {
|
||||
console.log("allocating random bytes for a new user object");
|
||||
opt = allocateBytes(Nacl.randomBytes(Exports.requiredBytes));
|
||||
// create a random v2 hash, since we don't need backwards compatibility
|
||||
userHash = opt.userHash = Hash.createRandomHash('drive');
|
||||
var secret = Hash.getSecrets('drive', userHash);
|
||||
opt.keys = secret.keys;
|
||||
opt.channelHex = secret.channel;
|
||||
}
|
||||
|
||||
// according to the location derived from the credentials which you entered
|
||||
loadUserObject(opt, waitFor(function (err, rt) {
|
||||
if (err) {
|
||||
waitFor.abort();
|
||||
return void cb('MODERN_REGISTRATION_INIT');
|
||||
}
|
||||
|
||||
//console.error(JSON.stringify(rt.proxy));
|
||||
|
||||
// export the realtime object you checked
|
||||
RT = rt;
|
||||
|
||||
var proxy = rt.proxy;
|
||||
if (isRegister && !isProxyEmpty(proxy) && (!proxy.edPublic || !proxy.edPrivate)) {
|
||||
console.error("INVALID KEYS");
|
||||
console.log(JSON.stringify(proxy));
|
||||
return;
|
||||
}
|
||||
|
||||
res.proxy = rt.proxy;
|
||||
res.realtime = rt.realtime;
|
||||
res.network = rt.network;
|
||||
|
||||
// they're registering...
|
||||
res.userHash = userHash;
|
||||
res.userName = uname;
|
||||
|
||||
// somehow they have a block present, but nothing in the user object it specifies
|
||||
// this shouldn't happen, but let's send feedback if it does
|
||||
if (!isRegister && isProxyEmpty(rt.proxy)) {
|
||||
// this really shouldn't happen, but let's handle it anyway
|
||||
Feedback.send('EMPTY_LOGIN_WITH_BLOCK');
|
||||
|
||||
//rt.network.disconnect(); // clean up after yourself
|
||||
waitFor.abort();
|
||||
return void cb('NO_SUCH_USER', res);
|
||||
}
|
||||
|
||||
// they tried to register, but those exact credentials exist
|
||||
if (isRegister && !isProxyEmpty(rt.proxy)) {
|
||||
//rt.network.disconnect();
|
||||
waitFor.abort();
|
||||
res.blockHash = blockHash;
|
||||
if (shouldImport) {
|
||||
setMergeAnonDrive();
|
||||
}
|
||||
|
||||
return void cb('ALREADY_REGISTERED', res);
|
||||
}
|
||||
|
||||
if (!isRegister && !isProxyEmpty(rt.proxy)) {
|
||||
waitFor.abort();
|
||||
if (shouldImport) {
|
||||
setMergeAnonDrive();
|
||||
}
|
||||
var l = Util.find(rt.proxy, ['settings', 'general', 'language']);
|
||||
var LS_LANG = "CRYPTPAD_LANG";
|
||||
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(undefined, blockHash, uname, function () {
|
||||
cb(void 0, res);
|
||||
});
|
||||
}
|
||||
|
||||
if (isRegister && isProxyEmpty(rt.proxy)) {
|
||||
proxy.edPublic = opt.edPublic;
|
||||
proxy.edPrivate = opt.edPrivate;
|
||||
proxy.curvePublic = opt.curvePublic;
|
||||
proxy.curvePrivate = opt.curvePrivate;
|
||||
proxy.login_name = uname;
|
||||
proxy[Constants.displayNameKey] = uname;
|
||||
if (shouldImport) {
|
||||
setMergeAnonDrive();
|
||||
} else {
|
||||
proxy.version = 11;
|
||||
}
|
||||
|
||||
Feedback.send('REGISTRATION', true);
|
||||
} else {
|
||||
Feedback.send('LOGIN', true);
|
||||
}
|
||||
|
||||
setTimeout(waitFor(function () {
|
||||
Realtime.whenRealtimeSyncs(rt.realtime, waitFor());
|
||||
}));
|
||||
}));
|
||||
}).nThen(function (waitFor) {
|
||||
require(['/common/pinpad.js'], waitFor(function (_Pinpad) {
|
||||
console.log("loaded rpc module");
|
||||
Pinpad = _Pinpad;
|
||||
}));
|
||||
}).nThen(function (waitFor) {
|
||||
// send an RPC to store the block which you created.
|
||||
console.log("initializing rpc interface");
|
||||
|
||||
Pinpad.create(RT.network, Block.keysToRPCFormat(res.opt.blockKeys), waitFor(function (e, _rpc) {
|
||||
if (e) {
|
||||
waitFor.abort();
|
||||
console.error(e); // INVALID_KEYS
|
||||
return void cb('RPC_CREATION_ERROR');
|
||||
}
|
||||
rpc = _rpc;
|
||||
console.log("rpc initialized");
|
||||
}));
|
||||
}).nThen(function (waitFor) {
|
||||
console.log("creating request to publish a login block");
|
||||
|
||||
// Finally, create the login block for the object you just created.
|
||||
var toPublish = {};
|
||||
|
||||
// XXX I did some basic testing and searching and could not find this attribute
|
||||
// actually being used anywhere. Including it means either supporting arbitrarily
|
||||
// large blocks (a DoS vector) or having registration fail for large usernames.
|
||||
// Can someone please double-check that removing this doesn't break anything?
|
||||
// --Aaron
|
||||
//toPublish[Constants.userNameKey] = uname;
|
||||
toPublish[Constants.userHashKey] = userHash;
|
||||
toPublish.edPublic = RT.proxy.edPublic;
|
||||
|
||||
Block.writeLoginBlock({
|
||||
blockKeys: blockKeys,
|
||||
content: toPublish
|
||||
}, waitFor(function (e) {
|
||||
if (e) {
|
||||
console.error(e);
|
||||
waitFor.abort();
|
||||
return void cb(e);
|
||||
}
|
||||
}));
|
||||
}).nThen(function (waitFor) {
|
||||
// confirm that the block was actually written before considering registration successful
|
||||
Util.fetch(blockUrl, waitFor(function (err /*, block */) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
waitFor.abort();
|
||||
return void cb(err);
|
||||
}
|
||||
|
||||
console.log("blockInfo available at:", blockHash);
|
||||
LocalStore.login(undefined, blockHash, uname, function () {
|
||||
cb(void 0, res);
|
||||
});
|
||||
}));
|
||||
});
|
||||
};
|
||||
Exports.redirect = function () {
|
||||
if (redirectTo) {
|
||||
var h = redirectTo;
|
||||
@ -591,6 +89,8 @@ define([
|
||||
if (hashing) { return void console.log("hashing is already in progress"); }
|
||||
hashing = true;
|
||||
|
||||
setMergeAnonDrive(shouldImport);
|
||||
|
||||
var proceed = function (result) {
|
||||
hashing = false;
|
||||
// NOTE: test is also use as a cb for the install page
|
||||
@ -612,7 +112,12 @@ define([
|
||||
// We need a setTimeout(cb, 0) otherwise the loading screen is only displayed
|
||||
// after hashing the password
|
||||
window.setTimeout(function () {
|
||||
Exports.loginOrRegister(uname, passwd, isRegister, shouldImport, onOTP, function (err, result) {
|
||||
Exports.loginOrRegister({
|
||||
uname,
|
||||
passwd,
|
||||
isRegister,
|
||||
onOTP
|
||||
}, function (err, result) {
|
||||
var proxy;
|
||||
if (result) { proxy = result.proxy; }
|
||||
|
||||
|
||||
@ -39,9 +39,8 @@ define([
|
||||
h('button#register.cp-secondary', Msg.login_register)
|
||||
),
|
||||
h('button.login', Msg.login_login),
|
||||
h('br'),
|
||||
h('button.login', Msg.login_login)
|
||||
])
|
||||
]),
|
||||
Config.sso ? h('div.cp-login-sso') : undefined
|
||||
]),
|
||||
h('div.col-md-3')
|
||||
]),
|
||||
|
||||
57
customize.dist/pages/ssoauth.js
Normal file
57
customize.dist/pages/ssoauth.js
Normal file
@ -0,0 +1,57 @@
|
||||
define([
|
||||
'/api/config',
|
||||
'jquery',
|
||||
'/common/hyperscript.js',
|
||||
'/common/common-interface.js',
|
||||
'/customize/messages.js',
|
||||
'/customize/pages.js'
|
||||
], function (Config, $, h, UI, Msg, Pages) {
|
||||
|
||||
Msg.ssoauth_header = "SSO authentication"; // XXX
|
||||
Msg.ssoauth_form_hint_register = "Add a CryptPad password for extra security or leave empty and continue";
|
||||
Msg.ssoauth_form_hint_login = "Please enter your CryptPad password";
|
||||
Msg.continue = "Continue";
|
||||
|
||||
return function () {
|
||||
document.title = Msg.ssoauth_header;
|
||||
|
||||
var frame = function (content) {
|
||||
return [
|
||||
h('div#cp-main', [
|
||||
Pages.infopageTopbar(),
|
||||
h('div.container.cp-container', [
|
||||
h('div.row.cp-page-title', h('h1', Msg.ssoauth_header)),
|
||||
].concat(content)),
|
||||
Pages.infopageFooter(),
|
||||
]),
|
||||
];
|
||||
};
|
||||
|
||||
return frame([
|
||||
h('div.row', [
|
||||
h('div.hidden.col-md-3'),
|
||||
h('div#userForm.form-group.col-md-6', [
|
||||
h('div.cp-ssoauth-pw', [
|
||||
h('p.register', Msg.ssoauth_form_hint_register),
|
||||
h('p.login', Msg.ssoauth_form_hint_login),
|
||||
h('input.form-control#password', {
|
||||
type: 'password',
|
||||
placeholder: Msg.login_password,
|
||||
}),
|
||||
h('input.form-control.register#passwordconfirm', {
|
||||
type: 'password',
|
||||
placeholder: Msg.login_confirm,
|
||||
}),
|
||||
h('div.cp-ssoauth-button',
|
||||
h('button.btn.btn-primary#cp-ssoauth-button', Msg.continue)
|
||||
)
|
||||
]),
|
||||
]),
|
||||
h('div.hidden.col-md-3'),
|
||||
])
|
||||
]);
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
|
||||
29
customize.dist/src/less2/pages/page-ssoauth.less
Normal file
29
customize.dist/src/less2/pages/page-ssoauth.less
Normal file
@ -0,0 +1,29 @@
|
||||
@import (reference) "../include/infopages.less";
|
||||
@import (reference) "../include/colortheme-all.less";
|
||||
@import (reference) "../include/alertify.less";
|
||||
@import (reference) "../include/forms.less";
|
||||
|
||||
&.cp-page-ssoauth {
|
||||
.infopages_main();
|
||||
.forms_main();
|
||||
|
||||
.alertify_main();
|
||||
|
||||
div.cp-ssoauth-pw {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.cp-regsiter {
|
||||
.login {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
&.cp-login {
|
||||
.register {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -57,6 +57,8 @@ $(function () {
|
||||
require([ '/install/main.js' ], function () {});
|
||||
} else if (/^\/recovery\//.test(pathname)) {
|
||||
require([ '/recovery/main.js' ], function () {});
|
||||
} else if (/^\/ssoauth\//.test(pathname)) {
|
||||
require([ '/ssoauth/main.js' ], function () {});
|
||||
} else if (/^\/login\//.test(pathname)) {
|
||||
require([ '/login/main.js' ], function () {});
|
||||
} else if (/^\/($|^\/index\.html$)/.test(pathname)) {
|
||||
|
||||
@ -1,28 +1,17 @@
|
||||
const Util = require("../common-util");
|
||||
const Commands = module.exports;
|
||||
const SSOUtils = require('../sso-utils');
|
||||
|
||||
const checkConfig = (Env) => {
|
||||
return Env && Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) && Env.sso.list.length;
|
||||
};
|
||||
const getProviderConfig = (Env, provider) => {
|
||||
if (!checkConfig) { return; }
|
||||
if (!provider) { return; }
|
||||
const data = Env.sso.list.find((cfg) => { return cfg.name === provider; });
|
||||
return data;
|
||||
};
|
||||
const nThen = require("nthen");
|
||||
const BlockStore = require("../storage/block");
|
||||
const Block = require("../commands/block");
|
||||
|
||||
const TYPES = SSOUtils.TYPES;
|
||||
const checkConfig = SSOUtils.checkConfig;
|
||||
const getProviderConfig = SSOUtils.getProviderConfig;
|
||||
const isValidConfig = SSOUtils.isValidConfig;
|
||||
|
||||
const isValidConfig = (cfg) => {
|
||||
if (!cfg) { return; }
|
||||
if (!cfg.type) { return; }
|
||||
const type = cfg.type.toLowerCase();
|
||||
const idp = TYPES[type];
|
||||
if (!idp) { return; }
|
||||
return idp.checkConfig(cfg);
|
||||
};
|
||||
|
||||
// Create an SSO authentication request
|
||||
const auth = Commands.SSO_AUTH = function (Env, body, cb) {
|
||||
if (!checkConfig(Env)) { return void cb('INVALID_SERVER_CONFIG'); }
|
||||
const { provider } = body;
|
||||
@ -58,6 +47,8 @@ auth.complete = function (Env, body, cb, req, res) {
|
||||
});
|
||||
};
|
||||
|
||||
// Receive authentication data from the IdP.
|
||||
// Read the auth request, create a JWT and get a block seed for this SSO user.
|
||||
const authCb = Commands.SSO_AUTH_CB = function (Env, body, cb, req) {
|
||||
if (!checkConfig(Env)) { return void cb('INVALID_SERVER_CONFIG'); }
|
||||
const { publicKey } = body;
|
||||
@ -68,8 +59,6 @@ const authCb = Commands.SSO_AUTH_CB = function (Env, body, cb, req) {
|
||||
if (err) { return void cb('ENOENT'); }
|
||||
const data = Util.tryParse(value);
|
||||
if (!data || !data.type || !TYPES[data.type]) { return void cb('INVALID_REQUEST_DATA'); }
|
||||
console.log('stored', data.publicKey);
|
||||
console.log('used', publicKey);
|
||||
if (publicKey !== data.publicKey) { return void cb('WRONG_SIGNATURE_KEYS'); }
|
||||
cb();
|
||||
});
|
||||
@ -86,20 +75,155 @@ authCb.complete = function (Env, body, cb, req) {
|
||||
const data = Util.tryParse(value);
|
||||
const cfg = getProviderConfig(Env, data.provider);
|
||||
const idp = TYPES[data.type];
|
||||
const register = data.register;
|
||||
idp.authCb(Env, cfg, ssotoken, url, (err, obj) => {
|
||||
if (err) { return void cb(err); }
|
||||
const {id, idpData} = obj;
|
||||
console.log(id, idpData);
|
||||
SSOUtils.makeUser(Env, id, (err, userData) => {
|
||||
if (err) { return void cb(err); }
|
||||
const {id, idpData, name} = obj;
|
||||
|
||||
// TODO
|
||||
// makeTempSession()
|
||||
|
||||
cb(void 0, { state: true });
|
||||
});
|
||||
let next = (user, isRegister) => {
|
||||
SSOUtils.createJWT(Env, id, data.provider, idpData, (err, jwt) => {
|
||||
if (err) { return void cb(err); }
|
||||
cb(void 0, {
|
||||
jwt: jwt,
|
||||
name: name,
|
||||
seed: user.seed,
|
||||
password: Boolean(user.password),
|
||||
register: isRegister
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (register) {
|
||||
SSOUtils.writeUser(Env, id, (err, userData) => {
|
||||
if (err && err.code === 'EEXIST') {
|
||||
return void SSOUtils.readUser(Env, id, (err, userData) => {
|
||||
if (err) { return void cb(err); }
|
||||
next(userData, false);
|
||||
});
|
||||
}
|
||||
if (err) { return void cb(err); }
|
||||
next(userData, true);
|
||||
});
|
||||
} else {
|
||||
SSOUtils.readUser(Env, id, (err, userData) => {
|
||||
if (err) { return void cb(err); }
|
||||
next(userData, false);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// XXX write block change-password? should be the same as otp but without otp code
|
||||
const register = Commands.SSO_WRITE_BLOCK = function (Env, body, cb) {
|
||||
const { publicKey, content } = body;
|
||||
|
||||
// they must provide a valid block public key
|
||||
if (!Block.isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
|
||||
if (publicKey !== content.publicKey) { return void cb("INVALID_KEY"); }
|
||||
const jwt = content.auth;
|
||||
if (!jwt) { return void cb('NO_JWT'); }
|
||||
|
||||
BlockStore.isAvailable(Env, publicKey, (err, result) => {
|
||||
if (err || result) {
|
||||
return void cb(err || 'EEXISTS');
|
||||
}
|
||||
// No block at this location: continue
|
||||
cb();
|
||||
});
|
||||
};
|
||||
register.complete = function (Env, body, cb) {
|
||||
const { publicKey, content } = body;
|
||||
const jwt = content.auth;
|
||||
const pw = content.hasPassword;
|
||||
let payload;
|
||||
let ssoUser;
|
||||
// XXX UPDATE sso_user add password boolean
|
||||
nThen((w) => {
|
||||
SSOUtils.checkJWT(Env, jwt, w((err, _payload) => {
|
||||
if (err) {
|
||||
w.abort();
|
||||
return void cb('INVALID_JWT_SIGNATURE');
|
||||
}
|
||||
payload = _payload;
|
||||
}));
|
||||
}).nThen((w) => {
|
||||
const { sub } = payload;
|
||||
SSOUtils.readUser(Env, sub, w((err, user) => {
|
||||
if (err) {
|
||||
w.abort();
|
||||
console.log(err, sub);
|
||||
return void cb('SSO_NO_USER');
|
||||
}
|
||||
ssoUser = user;
|
||||
}));
|
||||
}).nThen((w) => {
|
||||
const { sub } = payload;
|
||||
SSOUtils.writeBlock(Env, publicKey, sub, w((err) => {
|
||||
if (err) {
|
||||
w.abort();
|
||||
return void cb('SSO_BLOCK_WRITE');
|
||||
}
|
||||
}));
|
||||
}).nThen((w) => {
|
||||
Block.writeLoginBlock(Env, content, w((err) => {
|
||||
if (err) { w.abort(); }
|
||||
}));
|
||||
}).nThen((w) => {
|
||||
if (!pw) { return; }
|
||||
const { sub } = payload;
|
||||
ssoUser.password = true;
|
||||
SSOUtils.updateUser(Env, sub, ssoUser, w());
|
||||
}).nThen(() => {
|
||||
const { data, provider } = payload;
|
||||
SSOUtils.makeSession(Env, publicKey, provider, data, cb);
|
||||
});
|
||||
};
|
||||
|
||||
const login = Commands.SSO_VALIDATE = function (Env, body, cb) {
|
||||
const { publicKey, jwt } = body;
|
||||
|
||||
// they must provide a valid block public key
|
||||
if (!Block.isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
|
||||
if (!jwt) { return void cb('NO_JWT'); }
|
||||
|
||||
BlockStore.isAvailable(Env, publicKey, (err, result) => {
|
||||
if (err && !result) { return void cb(err); }
|
||||
// Block found
|
||||
cb();
|
||||
});
|
||||
};
|
||||
login.complete = function (Env, body, cb) {
|
||||
const { publicKey, jwt } = body;
|
||||
|
||||
let payload;
|
||||
nThen((w) => {
|
||||
SSOUtils.checkJWT(Env, jwt, w((err, _payload) => {
|
||||
if (err) {
|
||||
w.abort();
|
||||
return void cb('INVALID_JWT_SIGNATURE');
|
||||
}
|
||||
payload = _payload;
|
||||
}));
|
||||
}).nThen((w) => {
|
||||
const { sub } = payload;
|
||||
SSOUtils.readUser(Env, sub, w((err) => {
|
||||
if (err) {
|
||||
w.abort();
|
||||
console.log(err, sub);
|
||||
return void cb('SSO_NO_USER');
|
||||
}
|
||||
}));
|
||||
}).nThen((w) => {
|
||||
SSOUtils.readBlock(Env, publicKey, w((err) => {
|
||||
if (err) {
|
||||
w.abort();
|
||||
return void cb('SSO_BLOCK_WRITE');
|
||||
}
|
||||
}));
|
||||
}).nThen(() => {
|
||||
const { data, provider } = payload;
|
||||
SSOUtils.makeSession(Env, publicKey, provider, data, cb);
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
/* globals Buffer */
|
||||
const B32 = require("thirty-two");
|
||||
const OTP = require("notp");
|
||||
const JWT = require("jsonwebtoken");
|
||||
const nThen = require("nthen");
|
||||
const Util = require("../common-util");
|
||||
|
||||
@ -62,43 +61,10 @@ var decode32 = S => {
|
||||
// Allow user settings?
|
||||
var EXPIRATION = 7 * 24 * 3600 * 1000; // Sessions are valid 7 days
|
||||
|
||||
var createJWT = function (Env, sessionId, publicKey, cb) {
|
||||
JWT.sign({
|
||||
// this is a custom JWT field (not a standard) - we include a reference to the session
|
||||
// which is used to look up whether it has been revoked.
|
||||
ref: sessionId,
|
||||
// we specify in the token for what resource the token should be valid (their block's public key)
|
||||
sub: Util.escapeKeyCharacters(publicKey),
|
||||
exp: (+new Date()) + EXPIRATION
|
||||
}, Env.bearerSecret, {
|
||||
// token integrity is ensured with HMAC SHA512 with the server's bearerSecret
|
||||
// clients can inspect token parameters, but cannot modify them
|
||||
algorithm: 'HS512',
|
||||
// if you want it to expire you can set this for an arbitrary number of seconds in the future, but I won't assume that for now
|
||||
//expiresIn: (60 * 60 * 24 * 7)),
|
||||
}, function (err, token) {
|
||||
if (err) { return void cb(err); }
|
||||
cb(void 0, token);
|
||||
});
|
||||
};
|
||||
|
||||
// Create a session with a token for the given public key
|
||||
const makeSession = (Env, publicKey, cb) => {
|
||||
const sessionId = Sessions.randomId();
|
||||
var token;
|
||||
nThen(function (w) {
|
||||
/*createJWT(Env, sessionId, publicKey, w(function (err, _token) {
|
||||
if (err) {
|
||||
Env.Log.error("TOTP_VALIDATE_JWT_SIGN_ERROR", {
|
||||
error: Util.serializeError(err),
|
||||
publicKey: publicKey,
|
||||
});
|
||||
w.abort();
|
||||
return void cb("TOKEN_ERROR");
|
||||
}
|
||||
token = _token;
|
||||
}));*/
|
||||
}).nThen(function (w) {
|
||||
// store the token
|
||||
Sessions.write(Env, publicKey, sessionId, JSON.stringify({
|
||||
mfa: {
|
||||
@ -340,7 +306,7 @@ So, we should:
|
||||
};
|
||||
|
||||
// Same as TOTP_VALIDATE but without making a session at the end
|
||||
const check = Commands.TOTP_CHECK = function (Env, body, cb) {
|
||||
const check = Commands.TOTP_MFA_CHECK = function (Env, body, cb) {
|
||||
var { publicKey, auth } = body;
|
||||
const code = auth;
|
||||
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
|
||||
|
||||
@ -68,7 +68,7 @@ COMMANDS.REMOVE_BLOCK = NOAUTH.REMOVE_BLOCK;
|
||||
const TOTP = require("./challenge-commands/totp.js");
|
||||
COMMANDS.TOTP_SETUP = TOTP.TOTP_SETUP;
|
||||
COMMANDS.TOTP_VALIDATE = TOTP.TOTP_VALIDATE;
|
||||
COMMANDS.TOTP_CHECK = TOTP.TOTP_CHECK;
|
||||
COMMANDS.TOTP_MFA_CHECK = TOTP.TOTP_MFA_CHECK;
|
||||
COMMANDS.TOTP_REVOKE = TOTP.TOTP_REVOKE;
|
||||
COMMANDS.TOTP_WRITE_BLOCK = TOTP.TOTP_WRITE_BLOCK;
|
||||
COMMANDS.TOTP_REMOVE_BLOCK = TOTP.TOTP_REMOVE_BLOCK;
|
||||
@ -76,6 +76,8 @@ COMMANDS.TOTP_REMOVE_BLOCK = TOTP.TOTP_REMOVE_BLOCK;
|
||||
const SSO = require("./challenge-commands/sso.js");
|
||||
COMMANDS.SSO_AUTH = SSO.SSO_AUTH;
|
||||
COMMANDS.SSO_AUTH_CB = SSO.SSO_AUTH_CB;
|
||||
COMMANDS.SSO_WRITE_BLOCK = SSO.SSO_WRITE_BLOCK;
|
||||
COMMANDS.SSO_VALIDATE = SSO.SSO_VALIDATE;
|
||||
|
||||
var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-');
|
||||
|
||||
|
||||
@ -7,8 +7,8 @@ const nThen = require("nthen");
|
||||
const Util = require("./common-util");
|
||||
const Logger = require("./log");
|
||||
const AuthCommands = require("./http-commands");
|
||||
const JWT = require("jsonwebtoken");
|
||||
const MFA = require("./storage/mfa");
|
||||
const SSOUtils = require("./sso-utils");
|
||||
const Sessions = require("./storage/sessions");
|
||||
const cookieParser = require("cookie-parser");
|
||||
|
||||
@ -272,7 +272,7 @@ app.use('/block/', function (req, res, next) {
|
||||
|
||||
var authorization = req.headers.authorization;
|
||||
|
||||
var mfa_params, jwt_payload;
|
||||
var mfa_params, sso_params;
|
||||
nThen(function (w) {
|
||||
// First, check whether the block id in question has any MFA settings stored
|
||||
MFA.read(Env, name, w(function (err, content) {
|
||||
@ -281,8 +281,7 @@ app.use('/block/', function (req, res, next) {
|
||||
// in either case you can abort and fall through
|
||||
// allowing the static webserver to handle either case
|
||||
if (err && err.code === 'ENOENT') {
|
||||
w.abort();
|
||||
return void next();
|
||||
return;
|
||||
}
|
||||
|
||||
// we're not expecting other errors. the sensible thing is to fail
|
||||
@ -313,10 +312,30 @@ app.use('/block/', function (req, res, next) {
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
// Same for SSO settings
|
||||
SSOUtils.readBlock(Env, name, w(function (err, content) {
|
||||
if (err && err.code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
if (err) {
|
||||
Log.error('GET_BLOCK_METADATA', err);
|
||||
return void res.status(500).json({
|
||||
code: 500,
|
||||
error: "UNEXPECTED_ERROR",
|
||||
});
|
||||
}
|
||||
sso_params = content;
|
||||
}));
|
||||
}).nThen(function (w) {
|
||||
if (!mfa_params && !sso_params) {
|
||||
w.abort();
|
||||
next();
|
||||
}
|
||||
}).nThen(function (w) {
|
||||
// We should only be able to reach this logic
|
||||
// if we successfully loaded and parsed some JSON
|
||||
// representing the user's MFA settings.
|
||||
// representing the user's MFA and/or SSO settings.
|
||||
|
||||
// Failures at this point relate to insufficient or incorrect authorization.
|
||||
// This function standardizes how we reject such requests.
|
||||
@ -328,18 +347,18 @@ app.use('/block/', function (req, res, next) {
|
||||
var no = function () {
|
||||
w.abort();
|
||||
res.status(401).json({
|
||||
method: mfa_params.method,
|
||||
method: (mfa_params && mfa_params.method) || (sso_params && 'SSO'),
|
||||
code: 401
|
||||
});
|
||||
};
|
||||
|
||||
// if you are here it is because this block is protected by MFA.
|
||||
// if you are here it is because this block is protected by MFA or SSO.
|
||||
// they will need to provide a JSON Web Token, so we can reject them outright
|
||||
// if one is not present in their authorization header
|
||||
if (!authorization) { return void no(); }
|
||||
|
||||
// The authorization header should be of the form
|
||||
// "Authorization: Bearer <JWT>"
|
||||
// "Authorization: Bearer <SessionId>"
|
||||
// We can reject the request if it is malformed.
|
||||
let token = authorization.replace(/^Bearer\s+/, '').trim();
|
||||
if (!token) { return void no(); }
|
||||
@ -355,8 +374,12 @@ app.use('/block/', function (req, res, next) {
|
||||
|
||||
let content = Util.tryParse(contentStr);
|
||||
|
||||
if (mfa_params && !content.mfa) { return void no(); }
|
||||
if (sso_params && !content.sso) { return void no(); }
|
||||
|
||||
if (content.mfa && content.mfa.exp && ((+new Date()) > content.mfa.exp)) {
|
||||
Log.error("OTP_SESSION_EXPIRED", payload);
|
||||
Log.error("OTP_SESSION_EXPIRED", content.mfa);
|
||||
// XXX Only delete the mfa part
|
||||
Sessions.delete(Env, name, token, function (err) {
|
||||
if (err) {
|
||||
Log.error('SESSION_DELETE_EXPIRED_ERROR', err);
|
||||
@ -367,93 +390,34 @@ app.use('/block/', function (req, res, next) {
|
||||
return void no();
|
||||
}
|
||||
|
||||
// we could also check whether the content of the file matches the token,
|
||||
// but clients don't have any influence over the reference and can only
|
||||
// request to create tokens that are scoped to a public key they control.
|
||||
// I don' think there's any practical benefit to such a check.
|
||||
|
||||
// So, interpret the existence of a file in that location as the continued
|
||||
// validity of the session. Fall through and let the built-in webserver
|
||||
// handle the 404 or serving the file.
|
||||
next();
|
||||
});
|
||||
|
||||
// Otherwise we attempt to validate the token
|
||||
// Successful validation implies that the token was issued by the server
|
||||
// since only the server should possess the current bearer secret (unless it has leaked).
|
||||
|
||||
// It is still possible that the token is not valid for this particular resource,
|
||||
// so the algorithm (HMAC SHA512) only asserts its integrity, not its validity.
|
||||
/*
|
||||
JWT.verify(token, Env.bearerSecret, {
|
||||
algorithm: 'HS512',
|
||||
}, w(function (err, payload) {
|
||||
if (err) {
|
||||
// the token could not be validated for some reason.
|
||||
// it might have expired, the server might have rotated secrets,
|
||||
// it might not be well-formed, etc.
|
||||
// log and respond.
|
||||
Log.info('INVALID_JWT', {
|
||||
error: err,
|
||||
token: token,
|
||||
});
|
||||
return void no();
|
||||
}
|
||||
|
||||
// Now that we have the payload we can inspect its properties
|
||||
// and reject anything which is obviously wrong without requiring
|
||||
// any async I/O
|
||||
|
||||
// Tokens are issued with a "reference" - a random id which is
|
||||
// used alongside the block id to look up whether a given session
|
||||
// is still valid
|
||||
|
||||
// reject if it does not provide a lookup reference
|
||||
if (typeof(payload.ref) !== 'string') {
|
||||
Log.error("JWT_NO_REFERENCE", payload);
|
||||
return void no();
|
||||
}
|
||||
|
||||
// A JWT can optionally indicate a finite lifetime.
|
||||
// reject if it's too old
|
||||
if (payload.exp && ((+new Date()) > payload.exp)) {
|
||||
Log.error("JWT_EXPIRED", payload);
|
||||
Sessions.delete(Env, name, payload.ref, function (err) {
|
||||
if (err) {
|
||||
Log.error('JWT_SESSION_DELETE_EXPIRED_ERROR', err);
|
||||
return;
|
||||
if (content.sso) {
|
||||
SSOUtils.checkSession(Env, content.sso, (err, state, newState) => {
|
||||
if (err || !state) {
|
||||
// XXX Only delete the mfa part
|
||||
Sessions.delete(Env, name, token, function (err) {
|
||||
if (err) {
|
||||
Log.error('SSO_SESSION_DELETE_EXPIRED_ERROR', err);
|
||||
return;
|
||||
}
|
||||
Log.info('SSO_SESSION_DELETE_EXPIRED', err);
|
||||
});
|
||||
return void no();
|
||||
}
|
||||
Log.info('JWT_SESSION_DELETE_EXPIRED', err);
|
||||
});
|
||||
return void no();
|
||||
}
|
||||
|
||||
// A JWT indicates the subject (the block id) for which it is valid
|
||||
// reject if it does not match the block the client is trying to access
|
||||
if (payload.sub !== name) {
|
||||
Log.error("JWT_SUBJECT_MISMATCH", payload);
|
||||
return void no();
|
||||
}
|
||||
|
||||
// otherwise, it seems basically correct.
|
||||
Log.verbose("VALID_JWT", payload);
|
||||
|
||||
// remember the payload for subsequent asynchronous checks
|
||||
jwt_payload = payload;
|
||||
}));
|
||||
*/
|
||||
}).nThen(function () {
|
||||
// Finally, even if the JWT itself seems valid, the database
|
||||
// is the final authority as to whether the session is still valid,
|
||||
// as it might have been revoked
|
||||
/*
|
||||
Sessions.read(Env, name, jwt_payload.ref, function (err) {
|
||||
if (err) {
|
||||
Log.error('JWT_SESSION_READ_ERROR', err);
|
||||
return res.status(401).json({
|
||||
method: mfa_params.method,
|
||||
code: 401,
|
||||
if (newState) {
|
||||
content.sso = newState;
|
||||
// XXX We should produce a new session ID here and send it back to the user
|
||||
Sessions.update(Env, name, token, JSON.stringify(content), (err) => {
|
||||
if (err) {
|
||||
Log.error('SSO_SESSION_UPDATE_ERROR', err);
|
||||
return;
|
||||
}
|
||||
Log.info('SSO_SESSION_UPDATED', err);
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// we could also check whether the content of the file matches the token,
|
||||
@ -466,7 +430,6 @@ app.use('/block/', function (req, res, next) {
|
||||
// handle the 404 or serving the file.
|
||||
next();
|
||||
});
|
||||
*/
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -27,10 +27,6 @@ module.exports = {
|
||||
|
||||
const generators = OID.generators;
|
||||
const code_verifier = generators.codeVerifier();
|
||||
// store the code_verifier in your framework's session mechanism, if it is a cookie based solution
|
||||
// it should be httpOnly (not readable by javascript) and encrypted.
|
||||
|
||||
console.log(code_verifier);
|
||||
const code_challenge = generators.codeChallenge(code_verifier);
|
||||
const url = client.authorizationUrl({
|
||||
scope: 'openid email profile',// https://www.googleapis.com/auth/contacts.readonly', // https://www.google.com/m8/feeds
|
||||
@ -52,10 +48,11 @@ module.exports = {
|
||||
.then((tokenSet) => {
|
||||
let j = tokenSet;
|
||||
let c = tokenSet.claims();
|
||||
console.log(j, c);
|
||||
cb(void 0, {
|
||||
id: c.sub,
|
||||
name: c.name,
|
||||
idpData: {
|
||||
expires_at: j.expires_at,
|
||||
access_token: j.access_token,
|
||||
refresh_token: j.refresh_token,
|
||||
id_token: j.id_token
|
||||
@ -63,5 +60,25 @@ module.exports = {
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
checkSession: (Env, cfg, data, cb) => {
|
||||
const { refresh_token } = data;
|
||||
|
||||
let t = new OID.TokenSet(data);
|
||||
if (!t.expired()) { return void cb(void 0, true); }
|
||||
getClient(cfg, (err, client) => {
|
||||
client.refresh(t.refresh_token).then((j) => {
|
||||
const newData = {
|
||||
refresh_token: refresh_token,
|
||||
id_token: j.id_token,
|
||||
access_token: j.access_token,
|
||||
expires_at: j.expires_at
|
||||
};
|
||||
cb(void 0, true, newData);
|
||||
}, () => {
|
||||
// Error: can't renew token
|
||||
cb(void 0, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
107
lib/sso-utils.js
107
lib/sso-utils.js
@ -1,13 +1,39 @@
|
||||
const SSO = require("./storage/sso");
|
||||
const Sessions = require("./storage/sessions");
|
||||
const Nacl = require("tweetnacl/nacl-fast");
|
||||
const JWT = require("jsonwebtoken");
|
||||
const Util = require("./common-util");
|
||||
|
||||
const SSOUtils = module.exports;
|
||||
|
||||
// XXX const SAML = require('node-saml'); // https://www.npmjs.com/package/node-saml
|
||||
SSOUtils.TYPES = {
|
||||
const TYPES = SSOUtils.TYPES = {
|
||||
oidc: require('./plugins/sso/oidc')
|
||||
};
|
||||
|
||||
const checkConfig = SSOUtils.checkConfig = (Env) => {
|
||||
return Env && Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) && Env.sso.list.length;
|
||||
};
|
||||
const getProviderConfig = SSOUtils.getProviderConfig = (Env, provider) => {
|
||||
if (!checkConfig(Env)) { return; }
|
||||
if (!provider) { return; }
|
||||
const data = Env.sso.list.find((cfg) => { return cfg.name === provider; });
|
||||
return data;
|
||||
};
|
||||
SSOUtils.isValidConfig = (cfg) => {
|
||||
if (!cfg) { return; }
|
||||
if (!cfg.type) { return; }
|
||||
const type = cfg.type.toLowerCase();
|
||||
const idp = TYPES[type];
|
||||
if (!idp) { return; }
|
||||
return idp.checkConfig(cfg);
|
||||
};
|
||||
|
||||
SSOUtils.checkSession = (Env, sessionData, cb) => {
|
||||
const cfg = getProviderConfig(Env, sessionData.provider);
|
||||
const idp = TYPES[cfg.type.toLowerCase()];
|
||||
idp.checkSession(Env, cfg, sessionData.data, cb);
|
||||
};
|
||||
|
||||
SSOUtils.deleteRequest = (Env, id) => {
|
||||
SSO.request.delete(Env, id, (err) => {
|
||||
@ -34,15 +60,84 @@ SSOUtils.writeRequest = (Env, data, cb) => {
|
||||
};
|
||||
|
||||
|
||||
SSOUtils.makeUser = (Env, id, cb) => {
|
||||
const seed = Nacl.util.encodeBase64(Nacl.util.randomBytes(24));
|
||||
SSO.User.write(Env, id, JSON.stringify({
|
||||
SSOUtils.writeUser = (Env, id, cb) => {
|
||||
const seed = Nacl.util.encodeBase64(Nacl.randomBytes(24));
|
||||
SSO.user.write(Env, id, JSON.stringify({
|
||||
seed: seed,
|
||||
password: false // XXX maybe we don't need that flag, we can check if the block exists later
|
||||
password: false
|
||||
}), (err) => {
|
||||
if (err) { return void cb(err); }
|
||||
cb(void 0, { seed });
|
||||
});
|
||||
};
|
||||
SSOUtils.readUser = (Env, id, cb) => {
|
||||
SSO.user.read(Env, id, (err, user) => {
|
||||
if (err) { return void cb(err); }
|
||||
cb(void 0, Util.tryParse(user));
|
||||
});
|
||||
};
|
||||
SSOUtils.updateUser = (Env, id, data, cb) => {
|
||||
SSO.user.delete(Env, id, () => {
|
||||
SSO.user.write(Env, id, JSON.stringify(data), (err) => {
|
||||
if (err) { return void cb(err); }
|
||||
cb();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
SSOUtils.writeBlock = (Env, id, ssoID, cb) => {
|
||||
SSO.block.write(Env, id, JSON.stringify({
|
||||
id: ssoID
|
||||
}), (err) => {
|
||||
if (err) { return void cb(err); }
|
||||
cb();
|
||||
});
|
||||
};
|
||||
SSOUtils.readBlock = (Env, id, cb) => {
|
||||
SSO.block.read(Env, id, (err, blockData) => {
|
||||
if (err) { return void cb(err); }
|
||||
cb(void 0, Util.tryParse(blockData));
|
||||
});
|
||||
};
|
||||
|
||||
// Store the SSO data (tokens, etc.) in a JWT while waiting for the user's CryptPad password
|
||||
SSOUtils.createJWT = (Env, ssoId, provider, data, cb) => {
|
||||
JWT.sign({
|
||||
sub: ssoId,
|
||||
data: data,
|
||||
provider: provider
|
||||
}, Env.bearerSecret, {
|
||||
// token integrity is ensured with HMAC SHA512 with the server's bearerSecret
|
||||
// clients can inspect token parameters, but cannot modify them
|
||||
algorithm: 'HS512',
|
||||
// if you want it to expire you can set this for an arbitrary number of seconds in the future
|
||||
expiresIn: 300,
|
||||
}, function (err, token) {
|
||||
if (err) { return void cb(err); }
|
||||
cb(void 0, token);
|
||||
});
|
||||
};
|
||||
SSOUtils.checkJWT = (Env, token, cb) => {
|
||||
JWT.verify(token, Env.bearerSecret, {
|
||||
algorithm: 'HS512',
|
||||
}, function (err, payload) {
|
||||
if (err) {
|
||||
// the token could not be validated for some reason.
|
||||
// it might have expired, the server might have rotated secrets,
|
||||
// it might not be well-formed, etc.
|
||||
// log and respond.
|
||||
Env.Log.info('INVALID_JWT', {
|
||||
error: err,
|
||||
token: token,
|
||||
});
|
||||
return void cb('INVALID_JWT');
|
||||
}
|
||||
|
||||
// otherwise, it seems basically correct.
|
||||
Env.Log.verbose("VALID_JWT", payload);
|
||||
|
||||
cb(void 0, payload);
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
@ -61,7 +156,7 @@ SSOUtils.makeSession = (Env, publicKey, provider, ssoData, cb) => {
|
||||
publicKey: publicKey,
|
||||
sessionId: sessionId,
|
||||
});
|
||||
return void cb("SESSION_WRITE_ERROR");
|
||||
return void cb("SSO_NO_SESSION");
|
||||
}
|
||||
cb(void 0, {
|
||||
bearer: sessionId,
|
||||
|
||||
@ -42,6 +42,14 @@ Sessions.delete = function (Env, id, ref, cb) {
|
||||
Basic.delete(Env, path, cb);
|
||||
};
|
||||
|
||||
Sessions.update = function (Env, id, ref, data, cb) {
|
||||
var path = pathFromId(Env, id, ref);
|
||||
Basic.delete(Env, path, (err) => {
|
||||
if (err) { return void cb(err); }
|
||||
Basic.write(Env, path, data, cb);
|
||||
});
|
||||
};
|
||||
|
||||
Sessions.deleteUser = function (Env, id, cb) {
|
||||
if (!id || typeof(id) !== 'string') { return; }
|
||||
id = Util.escapeKeyCharacters(id);
|
||||
|
||||
@ -6,7 +6,8 @@ const SSO = module.exports;
|
||||
/* This module manages storage related to Single Sign-On (SSO) settings.
|
||||
|
||||
A first part (sso-requests) contains temporary files for sso authentication with a remote service
|
||||
and a second part (sso users) is a database of accounts registered via SSO.
|
||||
A second part (sso-users) is a database of accounts registered via SSO (SSO id ==> block seed)
|
||||
A third part (sso-blocks) is a databse of blocks that are sso-protected (block id ==> SSO id...)
|
||||
|
||||
The path for requests is based on the "authentication request" token depending on the type of SSO.
|
||||
The path for the user database is based on their persistent identifier (id) from the SSO.
|
||||
@ -21,7 +22,10 @@ var reqPathFromId = function (Env, id) {
|
||||
return pathFromId(Env, id, 'sso_request');
|
||||
};
|
||||
var userPathFromId = function (Env, id) {
|
||||
return pathFromId(Env, id, 'sso');
|
||||
return pathFromId(Env, id, 'sso_user');
|
||||
};
|
||||
var blockPathFromId = function (Env, id) {
|
||||
return pathFromId(Env, id, 'sso_block');
|
||||
};
|
||||
|
||||
const Req = SSO.request = {};
|
||||
@ -55,3 +59,18 @@ User.delete = function (Env, id, cb) {
|
||||
var path = userPathFromId(Env, id);
|
||||
Basic.delete(Env, path, cb);
|
||||
};
|
||||
|
||||
const Block = SSO.block = {};
|
||||
|
||||
Block.read = function (Env, id, cb) {
|
||||
var path = blockPathFromId(Env, id);
|
||||
Basic.read(Env, path, cb);
|
||||
};
|
||||
Block.write = function (Env, id, data, cb) {
|
||||
var path = blockPathFromId(Env, id);
|
||||
Basic.write(Env, path, data, cb);
|
||||
};
|
||||
Block.delete = function (Env, id, cb) {
|
||||
var path = blockPathFromId(Env, id);
|
||||
Basic.delete(Env, path, cb);
|
||||
};
|
||||
|
||||
520
www/common/common-login.js
Normal file
520
www/common/common-login.js
Normal file
@ -0,0 +1,520 @@
|
||||
define([
|
||||
'chainpad-listmap',
|
||||
'/bower_components/chainpad-crypto/crypto.js',
|
||||
'/common/common-util.js',
|
||||
'/common/outer/network-config.js',
|
||||
'/common/common-credential.js',
|
||||
'/bower_components/chainpad/chainpad.dist.js',
|
||||
'/common/common-realtime.js',
|
||||
'/common/common-constants.js',
|
||||
'/common/common-interface.js',
|
||||
'/common/common-feedback.js',
|
||||
'/common/outer/local-store.js',
|
||||
'/customize/messages.js',
|
||||
'/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, ServerCommand) {
|
||||
var Nacl = window.nacl;
|
||||
|
||||
var Exports = {
|
||||
requiredBytes: 192,
|
||||
};
|
||||
|
||||
var allocateBytes = Exports.allocateBytes = function (bytes) {
|
||||
var dispense = Cred.dispenser(bytes);
|
||||
|
||||
var opt = {};
|
||||
|
||||
// dispense 18 bytes of entropy for your encryption key
|
||||
var encryptionSeed = dispense(18);
|
||||
// 16 bytes for a deterministic channel key
|
||||
var channelSeed = dispense(16);
|
||||
// 32 bytes for a curve key
|
||||
var curveSeed = dispense(32);
|
||||
|
||||
var curvePair = Nacl.box.keyPair.fromSecretKey(new Uint8Array(curveSeed));
|
||||
opt.curvePrivate = Nacl.util.encodeBase64(curvePair.secretKey);
|
||||
opt.curvePublic = Nacl.util.encodeBase64(curvePair.publicKey);
|
||||
|
||||
// 32 more for a signing key
|
||||
var edSeed = opt.edSeed = dispense(32);
|
||||
|
||||
// 64 more bytes to seed an additional signing key
|
||||
var blockKeys = opt.blockKeys = Block.genkeys(new Uint8Array(dispense(64)));
|
||||
opt.blockHash = Block.getBlockHash(blockKeys);
|
||||
|
||||
// derive a private key from the ed seed
|
||||
var signingKeypair = Nacl.sign.keyPair.fromSeed(new Uint8Array(edSeed));
|
||||
|
||||
opt.edPrivate = Nacl.util.encodeBase64(signingKeypair.secretKey);
|
||||
opt.edPublic = Nacl.util.encodeBase64(signingKeypair.publicKey);
|
||||
|
||||
var keys = opt.keys = Crypto.createEditCryptor(null, encryptionSeed);
|
||||
|
||||
// 24 bytes of base64
|
||||
keys.editKeyStr = keys.editKeyStr.replace(/\//g, '-');
|
||||
|
||||
// 32 bytes of hex
|
||||
var channelHex = opt.channelHex = Util.uint8ArrayToHex(channelSeed);
|
||||
|
||||
// should never happen
|
||||
if (channelHex.length !== 32) { throw new Error('invalid channel id'); }
|
||||
|
||||
var channel64 = Util.hexToBase64(channelHex);
|
||||
|
||||
// we still generate a v1 hash because this function needs to deterministically
|
||||
// derive the same values as it always has. New accounts will generate their own
|
||||
// userHash values
|
||||
opt.userHash = '/1/edit/' + [channel64, opt.keys.editKeyStr].join('/') + '/';
|
||||
|
||||
return opt;
|
||||
};
|
||||
|
||||
var loginOptionsFromBlock = Exports.loginOptionsFromBlock = function (blockInfo) {
|
||||
var opt = {};
|
||||
var parsed = Hash.getSecrets('pad', blockInfo.User_hash);
|
||||
opt.channelHex = parsed.channel;
|
||||
opt.keys = parsed.keys;
|
||||
opt.edPublic = blockInfo.edPublic;
|
||||
return opt;
|
||||
};
|
||||
|
||||
var loadUserObject = Exports.loadUserObject = function (opt, cb) {
|
||||
var config = {
|
||||
websocketURL: NetConfig.getWebsocketURL(),
|
||||
channel: opt.channelHex,
|
||||
data: {},
|
||||
validateKey: opt.keys.validateKey, // derived validation key
|
||||
crypto: Crypto.createEncryptor(opt.keys),
|
||||
logLevel: 1,
|
||||
classic: true,
|
||||
ChainPad: ChainPad,
|
||||
owners: [opt.edPublic]
|
||||
};
|
||||
|
||||
var rt = opt.rt = Listmap.create(config);
|
||||
rt.proxy
|
||||
.on('ready', function () {
|
||||
setTimeout(function () { cb(void 0, rt); });
|
||||
})
|
||||
.on('disconnect', function (info) {
|
||||
cb('E_DISCONNECT', info);
|
||||
});
|
||||
};
|
||||
|
||||
var isProxyEmpty = Exports.isProxyEmpty = function (proxy) {
|
||||
var l = Object.keys(proxy).length;
|
||||
return l === 0 || (l === 2 && proxy._events && proxy.on);
|
||||
};
|
||||
|
||||
var legacyLogin = function (opt, isRegister, cb, res) {
|
||||
res = res || {};
|
||||
loadUserObject(opt, function (err, rt) {
|
||||
if (err) { return void cb(err); }
|
||||
|
||||
// if a proxy is marked as deprecated, it is because someone had a non-owned drive
|
||||
// but changed their password, and couldn't delete their old data.
|
||||
// if they are here, they have entered their old credentials, so we should not
|
||||
// allow them to proceed. In time, their old drive should get deleted, since
|
||||
// it will should be pinned by anyone's drive.
|
||||
if (rt.proxy[Constants.deprecatedKey]) {
|
||||
return void cb('NO_SUCH_USER');
|
||||
}
|
||||
|
||||
if (isRegister && isProxyEmpty(rt.proxy)) {
|
||||
// If they are trying to register,
|
||||
// and the proxy is empty, then there is no 'legacy user' either
|
||||
// so we should just shut down this session and disconnect.
|
||||
//rt.network.disconnect();
|
||||
return void cb(); // proceed to the next async block
|
||||
}
|
||||
|
||||
// they tried to just log in but there's no such user
|
||||
// and since we're here at all there is no modern-block
|
||||
if (!isRegister && isProxyEmpty(rt.proxy)) {
|
||||
return void cb('NO_SUCH_USER');
|
||||
}
|
||||
|
||||
// they tried to register, but those exact credentials exist
|
||||
if (isRegister && !isProxyEmpty(rt.proxy)) {
|
||||
Feedback.send('LOGIN', true);
|
||||
return void cb('ALREADY_REGISTERED');
|
||||
}
|
||||
|
||||
// if you are here, then there is no block, the user is trying
|
||||
// to log in. The proxy is **not** empty. All values assigned here
|
||||
// should have been deterministically created using their credentials
|
||||
// so setting them is just a precaution to keep things in good shape
|
||||
res.proxy = rt.proxy;
|
||||
res.realtime = rt.realtime;
|
||||
res.network = rt.network;
|
||||
|
||||
// they're registering...
|
||||
res.userHash = opt.userHash;
|
||||
res.userName = res.uname;
|
||||
|
||||
// export their signing key
|
||||
res.edPrivate = opt.edPrivate;
|
||||
res.edPublic = opt.edPublic;
|
||||
|
||||
// export their encryption key
|
||||
res.curvePrivate = opt.curvePrivate;
|
||||
res.curvePublic = opt.curvePublic;
|
||||
|
||||
// don't proceed past this async block.
|
||||
|
||||
// We have to call whenRealtimeSyncs asynchronously here because in the current
|
||||
// version of listmap, onLocal calls `chainpad.contentUpdate(newValue)`
|
||||
// asynchronously.
|
||||
// The following setTimeout is here to make sure whenRealtimeSyncs is called after
|
||||
// `contentUpdate` so that we have an update userDoc in chainpad.
|
||||
setTimeout(function () {
|
||||
Realtime.whenRealtimeSyncs(rt.realtime, function () {
|
||||
// the following stages are there to initialize a new drive
|
||||
// if you are registering
|
||||
LocalStore.login(res.userHash, undefined, res.userName, function () {
|
||||
setTimeout(function () { cb(void 0, res); });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var getProxyOpt = function (blockInfo) {
|
||||
var opt;
|
||||
if (blockInfo) {
|
||||
opt = loginOptionsFromBlock(blockInfo);
|
||||
opt.userHash = blockInfo.User_hash;
|
||||
} else {
|
||||
console.log("allocating random bytes for a new user object");
|
||||
opt = allocateBytes(Nacl.randomBytes(Exports.requiredBytes));
|
||||
// create a random v2 hash, since we don't need backwards compatibility
|
||||
opt.userHash = Hash.createRandomHash('drive');
|
||||
var secret = Hash.getSecrets('drive', opt.userHash);
|
||||
opt.keys = secret.keys;
|
||||
opt.channelHex = secret.channel;
|
||||
}
|
||||
|
||||
console.warn(opt);
|
||||
return opt;
|
||||
};
|
||||
|
||||
var modernLoginRegister = function (opt, isRegister, cb, res) {
|
||||
res = res || {};
|
||||
// according to the location derived from the credentials which you entered
|
||||
loadUserObject(opt, function (err, rt) {
|
||||
if (err) { return void cb('MODERN_REGISTRATION_INIT'); }
|
||||
|
||||
// export the realtime object you checked
|
||||
var RT = rt;
|
||||
|
||||
var proxy = rt.proxy;
|
||||
if (isRegister && !isProxyEmpty(proxy) && (!proxy.edPublic || !proxy.edPrivate)) {
|
||||
console.error("INVALID KEYS");
|
||||
console.log(JSON.stringify(proxy));
|
||||
return void cb(void 0, void 0, RT);
|
||||
}
|
||||
|
||||
res.proxy = rt.proxy;
|
||||
res.realtime = rt.realtime;
|
||||
res.network = rt.network;
|
||||
|
||||
// they're registering...
|
||||
res.userHash = opt.userHash;
|
||||
res.userName = res.uname;
|
||||
|
||||
// somehow they have a block present, but nothing in the user object it specifies
|
||||
// this shouldn't happen, but let's send feedback if it does
|
||||
if (!isRegister && isProxyEmpty(rt.proxy)) {
|
||||
// this really shouldn't happen, but let's handle it anyway
|
||||
Feedback.send('EMPTY_LOGIN_WITH_BLOCK');
|
||||
return void cb('NO_SUCH_USER');
|
||||
}
|
||||
|
||||
// they tried to register, but those exact credentials exist
|
||||
if (isRegister && !isProxyEmpty(rt.proxy)) {
|
||||
//rt.network.disconnect();
|
||||
return void cb('ALREADY_REGISTERED');
|
||||
}
|
||||
|
||||
if (!isRegister && !isProxyEmpty(rt.proxy)) {
|
||||
var l = Util.find(rt.proxy, ['settings', 'general', 'language']);
|
||||
var LS_LANG = "CRYPTPAD_LANG";
|
||||
if (l) { localStorage.setItem(LS_LANG, l); }
|
||||
|
||||
if (res.auth_token && res.auth_token.bearer) {
|
||||
LocalStore.setSessionToken(res.auth_token.bearer);
|
||||
}
|
||||
return void LocalStore.login(undefined, res.blockHash, res.uname, function () {
|
||||
cb(void 0, res, RT);
|
||||
});
|
||||
}
|
||||
|
||||
if (isRegister && isProxyEmpty(rt.proxy)) {
|
||||
proxy.edPublic = opt.edPublic;
|
||||
proxy.edPrivate = opt.edPrivate;
|
||||
proxy.curvePublic = opt.curvePublic;
|
||||
proxy.curvePrivate = opt.curvePrivate;
|
||||
proxy.login_name = res.uname;
|
||||
proxy[Constants.displayNameKey] = res.uname;
|
||||
proxy.version = 11;
|
||||
|
||||
Feedback.send('REGISTRATION', true);
|
||||
} else {
|
||||
Feedback.send('LOGIN', true);
|
||||
}
|
||||
|
||||
setTimeout(function () {
|
||||
Realtime.whenRealtimeSyncs(rt.realtime, function () {
|
||||
cb(void 0, void 0, RT);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Exports.loginOrRegister = function (config, cb) {
|
||||
let { uname, passwd, isRegister, onOTP, ssoAuth } = config;
|
||||
if (typeof(cb) !== 'function') { return; }
|
||||
|
||||
// Usernames are all lowercase. No going back on this one
|
||||
uname = uname.toLowerCase();
|
||||
|
||||
// validate inputs
|
||||
if (!Cred.isValidUsername(uname)) { return void cb('INVAL_USER'); }
|
||||
if (!Cred.isValidPassword(passwd) && !ssoAuth) { return void cb('INVAL_PASS'); }
|
||||
if (isRegister && !ssoAuth && !Cred.isLongEnoughPassword(passwd)) {
|
||||
return void cb('PASS_TOO_SHORT');
|
||||
}
|
||||
|
||||
// results...
|
||||
var res = {
|
||||
register: isRegister,
|
||||
uname: uname
|
||||
};
|
||||
if (ssoAuth && ssoAuth.name) { res.uname = ssoAuth.name; }
|
||||
|
||||
var RT, blockKeys, blockUrl;
|
||||
|
||||
nThen(function (waitFor) {
|
||||
// derive a predefined number of bytes from the user's inputs,
|
||||
// and allocate them in a deterministic fashion
|
||||
Cred.deriveFromPassphrase(uname, passwd, Exports.requiredBytes, waitFor(function (bytes) {
|
||||
res.opt = allocateBytes(bytes);
|
||||
res.blockHash = res.opt.blockHash;
|
||||
blockKeys = res.opt.blockKeys;
|
||||
}));
|
||||
}).nThen(function (waitFor) {
|
||||
// the allocated bytes can be used either in a legacy fashion,
|
||||
// or in such a way that a previously unused byte range determines
|
||||
// the location of a layer of indirection which points users to
|
||||
// an encrypted block, from which they can recover the location of
|
||||
// the rest of their data
|
||||
|
||||
// determine where a block for your set of keys would be stored
|
||||
blockUrl = Block.getBlockUrl(res.opt.blockKeys);
|
||||
|
||||
var TOTP_prompt = function (err, cb) {
|
||||
onOTP(function (code) {
|
||||
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);
|
||||
}, false, err);
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
var missingAuth;
|
||||
nThen(function (w) {
|
||||
Util.getBlock(blockUrl, {
|
||||
// request the block without credentials
|
||||
}, w(function (err, response) {
|
||||
if (err === 401) {
|
||||
missingAuth = response && response.method;
|
||||
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 (missingAuth !== 'SSO') { return; } // XXX multiple auth
|
||||
ServerCommand(res.opt.blockKeys.sign, {
|
||||
command: 'SSO_VALIDATE',
|
||||
jwt: ssoAuth.data,
|
||||
}, w(function (err, response) {
|
||||
if (err) {
|
||||
// XXX
|
||||
return;
|
||||
}
|
||||
res.auth_token = response;
|
||||
}));
|
||||
}).nThen(function (w) {
|
||||
if (missingAuth !== 'TOTP') { return; } // XXX multiple auth
|
||||
// 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(tries !== 2, 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.auth_token = response;
|
||||
done();
|
||||
});
|
||||
};
|
||||
ask();
|
||||
}).nThen(function (w) {
|
||||
Util.getBlock(blockUrl, res.auth_token, 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
|
||||
if (res.blockInfo) { return; }
|
||||
|
||||
var opt = res.opt;
|
||||
|
||||
// load the user's object using the legacy credentials
|
||||
legacyLogin(opt, isRegister, waitFor(function (err, data) {
|
||||
if (err) {
|
||||
waitFor.abort();
|
||||
return void cb(err);
|
||||
}
|
||||
if (!data) { return; } // Go to next block (modern registration)
|
||||
|
||||
// No error and data: success legacy login
|
||||
waitFor.abort();
|
||||
cb(void 0, data);
|
||||
}), res);
|
||||
}).nThen(function (waitFor) { // MODERN REGISTRATION / LOGIN
|
||||
var opt = getProxyOpt(res.blockInfo);
|
||||
|
||||
modernLoginRegister(opt, isRegister, waitFor(function (err, data, _RT) {
|
||||
if (err) {
|
||||
waitFor.abort();
|
||||
return void cb(err);
|
||||
}
|
||||
RT = _RT;
|
||||
if (!data) { return; } // Go to next block (modern registration)
|
||||
|
||||
// No error and data: success modern login
|
||||
waitFor.abort();
|
||||
cb(void 0, data);
|
||||
}), res);
|
||||
}).nThen(function (waitFor) {
|
||||
console.log("creating request to publish a login block");
|
||||
|
||||
// Finally, create the login block for the object you just created.
|
||||
var toPublish = {};
|
||||
toPublish[Constants.userHashKey] = res.userHash;
|
||||
toPublish.edPublic = RT.proxy.edPublic;
|
||||
|
||||
Block.writeLoginBlock({
|
||||
pw: Boolean(passwd),
|
||||
auth: ssoAuth,
|
||||
blockKeys: blockKeys,
|
||||
content: toPublish
|
||||
}, waitFor(function (e, res) {
|
||||
if (e === 'SSO_NO_SESSION') { return; } // account created, need re-login
|
||||
if (e) {
|
||||
console.error(e);
|
||||
waitFor.abort();
|
||||
return void cb(e);
|
||||
}
|
||||
if (res && res.bearer) {
|
||||
LocalStore.setSessionToken(res.bearer);
|
||||
}
|
||||
}));
|
||||
}).nThen(function (waitFor) {
|
||||
// confirm that the block was actually written before considering registration successful
|
||||
Util.getBlock(blockUrl, {}, waitFor(function (err /*, block */) {
|
||||
if (err && err !== 401) { // 401 is fine
|
||||
console.error(err);
|
||||
waitFor.abort();
|
||||
return void cb(err);
|
||||
}
|
||||
|
||||
console.log("blockInfo available at:", res.blockHash);
|
||||
LocalStore.login(undefined, res.blockHash, uname, function () {
|
||||
cb(void 0, res);
|
||||
});
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
return Exports;
|
||||
});
|
||||
@ -165,9 +165,7 @@ define([
|
||||
const { blockKeys, auth } = data;
|
||||
|
||||
var command = 'MFA_CHECK';
|
||||
if (auth && auth.type === 'TOTP') {
|
||||
command = 'TOTP_CHECK';
|
||||
}
|
||||
if (auth && auth.type) { command = `${auth.type.toUpperCase()}_` + command; }
|
||||
|
||||
ServerCommand(blockKeys.sign, {
|
||||
command: command,
|
||||
@ -175,15 +173,14 @@ define([
|
||||
}, cb);
|
||||
};
|
||||
Block.writeLoginBlock = function (data, cb) {
|
||||
const { content, blockKeys, oldBlockKeys, auth } = data;
|
||||
const { content, blockKeys, oldBlockKeys, auth, pw } = data;
|
||||
|
||||
var command = 'WRITE_BLOCK';
|
||||
if (auth && auth.type === 'TOTP') {
|
||||
command = 'TOTP_WRITE_BLOCK';
|
||||
}
|
||||
if (auth && auth.type) { command = `${auth.type.toUpperCase()}_` + command; }
|
||||
|
||||
var block = Block.serialize(JSON.stringify(content), blockKeys);
|
||||
block.auth = auth && auth.data;
|
||||
block.hasPassword = pw;
|
||||
block.registrationProof = oldBlockKeys && Block.proveAncestor(oldBlockKeys);
|
||||
|
||||
ServerCommand(blockKeys.sign, {
|
||||
@ -195,9 +192,7 @@ define([
|
||||
const { blockKeys, auth } = data;
|
||||
|
||||
var command = 'REMOVE_BLOCK';
|
||||
if (auth && auth.type === 'TOTP') {
|
||||
command = 'TOTP_REMOVE_BLOCK';
|
||||
}
|
||||
if (auth && auth.type) { command = `${auth.type.toUpperCase()}_` + command; }
|
||||
|
||||
ServerCommand(blockKeys.sign, {
|
||||
command: command,
|
||||
|
||||
@ -126,7 +126,8 @@ define([
|
||||
function (yes) {
|
||||
if (!yes) { return; }
|
||||
|
||||
Login.loginOrRegisterUI(uname, passwd, true, shouldImport, false, function (data) {
|
||||
Login.loginOrRegisterUI(uname, passwd, true, shouldImport,
|
||||
UI.getOTPScreen, false, function (data) {
|
||||
var proxy = data.proxy;
|
||||
if (!proxy || !proxy.edPublic) { UI.alert(Messages.error); return true; }
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
define([
|
||||
'/api/config',
|
||||
'jquery',
|
||||
'/common/hyperscript.js',
|
||||
'/common/cryptpad-common.js',
|
||||
'/customize/login.js',
|
||||
'/common/common-interface.js',
|
||||
@ -9,7 +11,7 @@ define([
|
||||
//'/common/test.js',
|
||||
|
||||
'css!/bower_components/components-font-awesome/css/font-awesome.min.css',
|
||||
], function ($, Cryptpad, Login, UI, Realtime, Feedback, LocalStore/*, Test */) {
|
||||
], function (Config, $, h, Cryptpad, Login, UI, Realtime, Feedback, LocalStore/*, Test */) {
|
||||
if (window.top !== window) { return; }
|
||||
$(function () {
|
||||
var $checkImport = $('#import-recent');
|
||||
@ -19,6 +21,27 @@ define([
|
||||
return;
|
||||
}
|
||||
|
||||
if (Config.sso) {
|
||||
// TODO
|
||||
// Config.sso.force => no legacy login allowed
|
||||
// Config.sso.password => cp password required or forbidden
|
||||
// Config.sso.list => list of configured identity providers
|
||||
var $sso = $('div.cp-login-sso');
|
||||
var list = Config.sso.list.map(function (name) {
|
||||
var b = h('button.btn.btn-secondary', name);
|
||||
var $b = $(b).click(function () {
|
||||
$b.prop('disabled', 'disabled');
|
||||
Login.ssoAuth(name, function (err, data) {
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
});
|
||||
});
|
||||
return b;
|
||||
});
|
||||
$sso.append(list);
|
||||
}
|
||||
|
||||
/* Log in UI */
|
||||
// deferred execution to avoid unnecessary asset loading
|
||||
var loginReady = function (cb) {
|
||||
|
||||
@ -62,13 +62,11 @@ define([
|
||||
var $b = $(b).click(function () {
|
||||
console.log('sso register click:', name);
|
||||
$b.prop('disabled', 'disabled');
|
||||
Login.ssoRegister(name, function (err, data) {
|
||||
console.error(err, data);
|
||||
Login.ssoAuth(name, function (err, data) {
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
});
|
||||
// XXX Server command
|
||||
});
|
||||
return b;
|
||||
});
|
||||
@ -152,7 +150,8 @@ define([
|
||||
function (yes) {
|
||||
if (!yes) { return; }
|
||||
|
||||
Login.loginOrRegisterUI(uname, passwd, true, shouldImport, false /*Test.testing*/, function () {
|
||||
Login.loginOrRegisterUI(uname, passwd, true, shouldImport,
|
||||
UI.getOTPScreen, false /*Test.testing*/, function () {
|
||||
if (test) {
|
||||
localStorage.clear();
|
||||
test.pass();
|
||||
|
||||
@ -6,7 +6,9 @@
|
||||
<meta content="text/html; charset=utf-8" http-equiv="content-type"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<link rel="icon" type="image/png" href="/customize/favicon/main-favicon.png" id="favicon"/>
|
||||
<script async data-bootload="/ssoauth/main.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
|
||||
<script src="/customize/pre-loading.js?ver=1.1"></script>
|
||||
<link href="/customize/src/pre-loading.css?ver=1.0" rel="stylesheet" type="text/css">
|
||||
<script async data-bootload="/customize/template.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
|
||||
</head>
|
||||
<body class="html">
|
||||
<noscript></noscript>
|
||||
|
||||
@ -1,3 +1,142 @@
|
||||
define(['/customize/login.js'], function (Login) {
|
||||
Login.ssoRegisterCb();
|
||||
define([
|
||||
'/api/config',
|
||||
'jquery',
|
||||
'/common/hyperscript.js',
|
||||
'/common/common-util.js',
|
||||
'/common/common-credential.js',
|
||||
'/common/common-interface.js',
|
||||
'/common/common-login.js',
|
||||
'/common/common-constants.js',
|
||||
'/common/outer/http-command.js',
|
||||
'/common/outer/local-store.js',
|
||||
'/common/outer/login-block.js',
|
||||
'/customize/messages.js',
|
||||
|
||||
'/bower_components/tweetnacl/nacl-fast.min.js',
|
||||
], function (ApiConfig, $, h, Util, Cred, UI, Login, Constants,
|
||||
ServerCommand, LocalStore, Block, Messages) {
|
||||
if (window.top !== window) { return; }
|
||||
|
||||
let Nacl = window.nacl;
|
||||
|
||||
let ssoAuthCb = function (cb) {
|
||||
var b64Keys = Util.tryParse(localStorage.CP_sso_auth);
|
||||
if (!b64Keys) {
|
||||
UI.errorLoadingScreen("MISSING_SIGNATURE_KEYS");
|
||||
return;
|
||||
}
|
||||
var keys = {
|
||||
secretKey: Nacl.util.decodeBase64(b64Keys.s),
|
||||
publicKey: Nacl.util.decodeBase64(b64Keys.p)
|
||||
};
|
||||
ServerCommand(keys, {
|
||||
command: 'SSO_AUTH_CB',
|
||||
url: window.location.href
|
||||
}, function (err, data) {
|
||||
delete localStorage.CP_sso_auth;
|
||||
document.cookie = 'ssotoken=; Max-Age=-99999999;';
|
||||
cb(err, data);
|
||||
});
|
||||
};
|
||||
let ssoLoginRegister = function (seed, pw, jwt, name, isRegister) {
|
||||
Login.loginOrRegister({
|
||||
uname: seed,
|
||||
passwd: pw,
|
||||
isRegister: isRegister,
|
||||
onOTP: UI.getOTPScreen,
|
||||
ssoAuth: {
|
||||
name: name,
|
||||
type: 'SSO',
|
||||
data: jwt
|
||||
}
|
||||
}, function (err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return void UI.warn(Messages.error);
|
||||
}
|
||||
window.location.href = '/drive/';
|
||||
});
|
||||
};
|
||||
|
||||
$(function () {
|
||||
if (!ApiConfig.sso) { return void UI.errorLoadingScreen(Messages.error); }
|
||||
|
||||
UI.addLoadingScreen();
|
||||
ssoAuthCb(function (err, data) {
|
||||
if (err || !data || !data.jwt) {
|
||||
console.error(err || 'NO_DATA');
|
||||
return void UI.warn(Messages.error);
|
||||
}
|
||||
let jwt = data.jwt;
|
||||
let seed = data.seed;
|
||||
let name = data.name;
|
||||
$('body').addClass(data.register ? 'cp-register' : 'cp-login');
|
||||
|
||||
UI.removeLoadingScreen();
|
||||
|
||||
|
||||
// Login with a password OR register and password allowed
|
||||
let $button = $('button#cp-ssoauth-button');
|
||||
let $pw = $('#password');
|
||||
let $pw2 = $('#passwordconfirm');
|
||||
let next = (pw) => {
|
||||
// TODO login err ==> re-enable button
|
||||
|
||||
setTimeout(() => { // First setTimeout to remove mobile devices' keyboard
|
||||
UI.addLoadingScreen({
|
||||
loadingText: Messages.login_hashing,
|
||||
hideTips: true,
|
||||
});
|
||||
setTimeout(function () { // Second timeout for the loading screen befofe Scrypt
|
||||
ssoLoginRegister(seed, pw, jwt, name, data.register);
|
||||
}, 100);
|
||||
}, 100);
|
||||
|
||||
};
|
||||
|
||||
// Existing account, no CP password, continue
|
||||
if (!data.register && !data.password) {
|
||||
return void next('');
|
||||
}
|
||||
|
||||
// Registration and CP password disabled, continue
|
||||
if (data.register && !ApiConfig.sso.password) {
|
||||
return void next('');
|
||||
}
|
||||
|
||||
$('div.cp-ssoauth-pw').show();
|
||||
|
||||
$button.click(() => {
|
||||
let pw = $pw.val();
|
||||
if (data.register && pw !== $pw2.val()) {
|
||||
return void UI.warn(Messages.register_passwordsDontMatch);
|
||||
}
|
||||
if (data.register && pw && !Cred.isLongEnoughPassword(pw)) {
|
||||
return void UI.warn(Messages.register_passwordTooShort);
|
||||
}
|
||||
$button.prop('disabled', 'disabled');
|
||||
|
||||
if (data.register) {
|
||||
var span = h('span', [
|
||||
h('h2', [
|
||||
h('i.fa.fa-warning'),
|
||||
' ',
|
||||
Messages.register_warning,
|
||||
]),
|
||||
Messages.register_warning_note
|
||||
]);
|
||||
UI.confirm(span, function (yes) {
|
||||
if (!yes) {
|
||||
$button.removeAttr('disabled');
|
||||
return;
|
||||
}
|
||||
next(pw);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next(pw);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user