SSO: OIDC login and register

This commit is contained in:
yflory 2023-06-27 16:04:32 +02:00
parent 0c94c1a602
commit b93b5eae4e
20 changed files with 1171 additions and 706 deletions

View File

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

View File

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

View 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'),
])
]);
};
});

View 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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