diff --git a/customize.dist/login.js b/customize.dist/login.js index c0b06fa04..fb9d01365 100644 --- a/customize.dist/login.js +++ b/customize.dist/login.js @@ -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; } diff --git a/customize.dist/pages/login.js b/customize.dist/pages/login.js index c6774c222..53b569af3 100644 --- a/customize.dist/pages/login.js +++ b/customize.dist/pages/login.js @@ -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') ]), diff --git a/customize.dist/pages/ssoauth.js b/customize.dist/pages/ssoauth.js new file mode 100644 index 000000000..691d18470 --- /dev/null +++ b/customize.dist/pages/ssoauth.js @@ -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'), + ]) + ]); + }; + +}); + + diff --git a/customize.dist/src/less2/pages/page-ssoauth.less b/customize.dist/src/less2/pages/page-ssoauth.less new file mode 100644 index 000000000..a2c0e0141 --- /dev/null +++ b/customize.dist/src/less2/pages/page-ssoauth.less @@ -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; + } + } + +} + + diff --git a/customize.dist/template.js b/customize.dist/template.js index b5eca8b2f..bead8f4a7 100644 --- a/customize.dist/template.js +++ b/customize.dist/template.js @@ -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)) { diff --git a/lib/challenge-commands/sso.js b/lib/challenge-commands/sso.js index b00e3a89e..385fa6d70 100644 --- a/lib/challenge-commands/sso.js +++ b/lib/challenge-commands/sso.js @@ -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); + }); + +}; diff --git a/lib/challenge-commands/totp.js b/lib/challenge-commands/totp.js index 45f2cedd6..ddcfb31a5 100644 --- a/lib/challenge-commands/totp.js +++ b/lib/challenge-commands/totp.js @@ -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'); } diff --git a/lib/http-commands.js b/lib/http-commands.js index 2c19de250..8e397b5b1 100644 --- a/lib/http-commands.js +++ b/lib/http-commands.js @@ -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, '-'); diff --git a/lib/http-worker.js b/lib/http-worker.js index 855062c59..50fba51c0 100644 --- a/lib/http-worker.js +++ b/lib/http-worker.js @@ -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 " + // "Authorization: Bearer " // 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(); }); - */ }); }); diff --git a/lib/plugins/sso/oidc.js b/lib/plugins/sso/oidc.js index a61ce0846..caa904a85 100644 --- a/lib/plugins/sso/oidc.js +++ b/lib/plugins/sso/oidc.js @@ -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); + }); + }); } }; diff --git a/lib/sso-utils.js b/lib/sso-utils.js index 1bad43d5d..3e11047e2 100644 --- a/lib/sso-utils.js +++ b/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, diff --git a/lib/storage/sessions.js b/lib/storage/sessions.js index f31151ec8..30283dc47 100644 --- a/lib/storage/sessions.js +++ b/lib/storage/sessions.js @@ -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); diff --git a/lib/storage/sso.js b/lib/storage/sso.js index 65110ee30..b28203fd5 100644 --- a/lib/storage/sso.js +++ b/lib/storage/sso.js @@ -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); +}; diff --git a/www/common/common-login.js b/www/common/common-login.js new file mode 100644 index 000000000..007c0bbfc --- /dev/null +++ b/www/common/common-login.js @@ -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; +}); diff --git a/www/common/outer/login-block.js b/www/common/outer/login-block.js index 67eaaa128..ccddfb780 100644 --- a/www/common/outer/login-block.js +++ b/www/common/outer/login-block.js @@ -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, diff --git a/www/install/main.js b/www/install/main.js index e7c03e3cd..a8352e588 100644 --- a/www/install/main.js +++ b/www/install/main.js @@ -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; } diff --git a/www/login/main.js b/www/login/main.js index edbc38f16..ad3483533 100644 --- a/www/login/main.js +++ b/www/login/main.js @@ -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) { diff --git a/www/register/main.js b/www/register/main.js index 3900fc855..729518819 100644 --- a/www/register/main.js +++ b/www/register/main.js @@ -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(); diff --git a/www/ssoauth/index.html b/www/ssoauth/index.html index a7bdb9b14..11e9eb7b7 100644 --- a/www/ssoauth/index.html +++ b/www/ssoauth/index.html @@ -6,7 +6,9 @@ - + + + diff --git a/www/ssoauth/main.js b/www/ssoauth/main.js index c556e2814..4272d87fa 100644 --- a/www/ssoauth/main.js +++ b/www/ssoauth/main.js @@ -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); + }); + }); + }); });