Merge branch 'authsso' into staging

This commit is contained in:
yflory 2023-12-13 15:14:31 +01:00
commit df6b0a0e98
40 changed files with 1872 additions and 627 deletions

2
.gitignore vendored
View File

@ -13,6 +13,7 @@ customization
.*.swp
*.db
/customize/
/lib/plugins
customize
messages.log
.DS_Store
@ -27,3 +28,4 @@ block/
logs/
privileged.conf
config/config.js
config/sso.js

View File

@ -117,6 +117,20 @@ module.exports = {
*/
// maxWorkers: 4,
/* =====================
* Sessions
* ===================== */
/* Accounts can be protected with an OTP (One Time Password) system
* to add a second authentication layer. Such accounts use a session
* with a given lifetime after which they are logged out and need
* to be re-authenticated. You can configure the lifetime of these
* sessions here.
*
* defaults to 7 days
*/
//otpSessionExpiration: 7*24, // hours
/* =====================
* Admin
* ===================== */

22
config/sso.example.js Normal file
View File

@ -0,0 +1,22 @@
module.exports = {
// Enable SSO login on this instance
enabled: false,
// Block registration for non-SSO users on this instance
enforced: false,
// Allow users to add an additional CryptPad password to their SSO account
cpPassword: false,
// List of SSO providers
list: [
/*
{
name: 'google',
type: 'oidc',
url: 'https://accounts.google.com',
client_id: "{your_client_id}",
client_secret: "{your_client_secret}",
jwt_alg: 'RS256' (optional)
}
*/
]
};

View File

@ -8,6 +8,7 @@ define([
'/components/chainpad-crypto/crypto.js',
'/common/common-util.js',
'/common/outer/network-config.js',
'/common/common-login.js',
'/common/common-credential.js',
'/components/chainpad/chainpad.dist.js',
'/common/common-realtime.js',
@ -24,14 +25,14 @@ define([
'/components/tweetnacl/nacl-fast.min.js',
'/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, h, 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;
@ -44,511 +45,31 @@ define([
redirectTo = newPad.href;
}
};
if (window.location.hash) {
setRedirectTo();
}
if (window.location.hash) { setRedirectTo(); }
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 cb = Util.once(Util.mkAsync(_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('error', function (info) {
cb(info.type, {reason: info.message});
})
.on('disconnect', function (info) {
cb('E_DISCONNECT', info);
Exports.ssoAuth = function (provider, cb) {
var keys = Nacl.sign.keyPair();
localStorage.CP_sso_auth = JSON.stringify({
s: Nacl.util.encodeBase64(keys.secretKey),
p: Nacl.util.encodeBase64(keys.publicKey)
});
};
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"?
ServerCommand(keys, {
command: 'SSO_AUTH',
provider: provider,
register: true
}, cb);
}, false, err);
};
Exports.ssoLogin = function () {
};
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);
});
Exports.allocateBytes = Login.allocateBytes;
Exports.loadUserObject = Login.loadUserObject;
var setMergeAnonDrive = function (value) {
Exports.mergeAnonDrive = Boolean(value);
};
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");
}
if (err === 404 && response && response.reason) {
waitFor.abort();
w.abort();
/*
// the following block prevent users from re-using an old password
if (isRegister) { return void cb('HAS_PLACEHOLDER'); }
*/
return void cb('DELETED_USER', response);
}
// 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();
if (err === 'EDELETED') { return void cb('DELETED_USER', rt); }
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();
if (err === 'EDELETED') { return void cb('DELETED_USER', rt); }
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 = {};
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;
@ -573,6 +94,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
@ -594,7 +117,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) {
Login.loginOrRegister({
uname,
passwd,
isRegister,
onOTP
}, function (err, result) {
var proxy;
if (result) { proxy = result.proxy; }

View File

@ -11,13 +11,18 @@ define([
], function (h, UI, Msg, Pages, Config) {
return function () {
document.title = Msg.login_login;
var ssoEnabled = (Config.sso && Config.sso.list && Config.sso.list.length) ?'': '.cp-hidden';
var ssoEnforced = (Config.sso && Config.sso.force) ? '.cp-hidden' : '';
Msg.sso_login_description = "Login from SSO...."; // XXX
return [h('div#cp-main', [
Pages.infopageTopbar(),
h('div.container.cp-container', [
h('div.row.cp-page-title', h('h1', Msg.login_login)),
h('div.row', [
h('div.col-md-3'),
h('div#userForm.form-group.hidden.col-md-6', [
h('div.col-md-3'+ssoEnforced),
h('div#userForm.form-group.col-md-6'+ssoEnforced, [
h('div.cp-login-instance', Msg._getKey('login_instance', [ Pages.Instance.name ])),
h('div.big-container', [
h('div.input-container', [
@ -48,17 +53,22 @@ define([
]),
h('div.extra', [
(Config.restrictRegistration?
undefined:
h('div'):
h('a#register', {
href: "/register/",
}, Msg.login_register)
),
h('button.login', Msg.login_login)
])
h('button.login', Msg.login_login),
]),
h('div.col-md-3')
]),
h('div.row', [
h('div.col-md-3'),
h('div.col-md-3'+ssoEnabled),
h('div#ssoForm.form-group.col-md-6'+ssoEnabled, [
h('div.cp-login-sso', Msg.sso_login_description)
]),
h('div.col-md-3'+ssoEnabled),
]),
h('div.row.cp-login-encryption', [
h('div.col-md-3'),
h('div.col-md-6', Msg.register_warning_note),
h('div.col-md-3'),

View File

@ -14,6 +14,10 @@ define([
document.title = Msg.register_header;
var tos = $(UI.createCheckbox('accept-terms')).find('.cp-checkmark-label').append(Msg.register_acceptTerms).parent()[0];
var ssoEnabled = (Config.sso && Config.sso.list && Config.sso.list.length) ?'': '.cp-hidden';
var ssoEnforced = (Config.sso && Config.sso.force) ? '.cp-hidden' : '';
Msg.sso_register_description = "Register from SSO...."; // XXX
var termsLink = Pages.customURLs.terms;
$(tos).find('a').attr({
href: termsLink,
@ -53,7 +57,7 @@ define([
Pages.setHTML(h('div.cp-register-notes'), Msg.register_notes)
]),
h('div.cp-reg-form.col-md-6', [
h('div#userForm.form-group.hidden', [
h('div#userForm.form-group'+ssoEnforced, [
h('div.cp-register-instance', [
Msg._getKey('register_instance', [Pages.Instance.name]),
h('br'),
@ -95,8 +99,11 @@ define([
UI.createCheckbox('import-recent', Msg.register_importRecent, true)
]),
termsCheck,
h('button#register', Msg.login_register)
])
h('button#register', Msg.login_register),
]),
h('div#ssoForm.form-group.col-md-6'+ssoEnabled, [
h('div.cp-register-sso', Msg.sso_register_description)
]),
]),
])
]);

View File

@ -0,0 +1,56 @@
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.cp-ssoauth-pw', [
h('p.cp-isregister.cp-login-instance', Msg.ssoauth_form_hint_register),
h('p.cp-islogin.cp-login-instance', Msg.ssoauth_form_hint_login),
h('input.form-control#password', {
type: 'password',
placeholder: Msg.login_password,
}),
h('input.form-control.cp-isregister#passwordconfirm', {
type: 'password',
placeholder: Msg.login_confirm,
}),
h('div.cp-ssoauth-button.extra',
h('div'),
h('button.login#cp-ssoauth-button', Msg.continue)
)
]),
h('div.hidden.col-md-3'),
])
]);
};
});

View File

@ -0,0 +1,50 @@
@import (reference) "../include/infopages.less";
@import (reference) "../include/colortheme-all.less";
@import (reference) "../include/alertify.less";
@import (reference) "../include/forms.less";
.login_main() {
#userForm, #ssoForm {
.cp-shadow();
background-color: @cp_static-card-bg;
padding: 10px;
margin: 0;
border-radius: @infopages-radius-L;
.cp-login-instance, .cp-login-sso-description {
margin-bottom: 5px;
}
.form-control {
border-radius: @infopages-radius;
color: @cryptpad_text_col;
background-color: @cp_forms-bg;
margin-bottom: 10px;
&:focus {
border-color: @cryptpad_color_brand;
}
.tools_placeholder-color();
}
.checkbox-container {
color: @cryptpad_text_col;
}
}
#ssoForm {
margin-top: 10px;
button {
margin: 0 5px;
}
.cp-login-sso {
display: flex;
align-items: center;
}
}
.cp-default-label {
display: none;
}
.extra {
margin-top: 1em;
button.login {
margin-right: 0px;
}
}
}

View File

@ -9,6 +9,7 @@
@import (reference) "../include/alertify.less";
@import (reference) "../include/checkmark.less";
@import (reference) "../include/forms.less";
@import (reference) "../include/login.less";
&.cp-page-login {
.infopages_main();
@ -25,42 +26,18 @@
}
}
.cp-container {
#userForm {
.cp-shadow();
background-color: @cp_static-card-bg;
padding: 10px;
margin: 0 10px 10px 10px;
border-radius: @infopages-radius-L;
.cp-login-instance {
margin-bottom: 5px;
}
.form-control {
border-radius: @infopages-radius;
color: @cryptpad_text_col;
background-color: @cp_forms-bg;
margin-bottom: 10px;
&:focus {
border-color: @cryptpad_color_brand;
}
.tools_placeholder-color();
}
.checkbox-container {
color: @cryptpad_text_col;
}
.cp-hidden {
display: none !important;
}
.login_main();
.align-items-center {
box-shadow: 0 5px 15px @cp_shadow-color;
background: @cryptpad_color_white;
}
.extra {
margin-top: 1em;
button.login {
margin-right: 0px;
}
}
.cp-default-label {
display: none;
}
.cp-login-encryption {
margin-top: 10px;
}
.cp-password-form {

View File

@ -9,6 +9,7 @@
@import (reference) "../include/alertify.less";
@import (reference) "../include/checkmark.less";
@import (reference) "../include/forms.less";
@import (reference) "../include/login.less";
&.cp-page-register {
.infopages_main();
@ -91,32 +92,25 @@
z-index: 0;
}
}
#userForm {
padding: 15px;
background-color: @cp_static-card-bg;
.cp-hidden {
display: none !important;
}
.login_main();
#userForm, #ssoForm {
position: relative;
z-index: 2;
margin-bottom: 100px;
border-radius: @infopages-radius-L;
.cp-shadow();
.form-control {
border-radius: @infopages-radius;
color: @cryptpad_text_col;
background-color: @cp_forms-bg;
margin-bottom: 10px;
&:focus {
border-color: @cryptpad_color_brand;
}
.tools_placeholder-color();
}
max-width: 100%;
padding: 15px;
.checkbox-container {
margin-top: 0.5rem;
color: @cryptpad_text_col;
}
button#register {
margin-top: 10px;
}
}
#ssoForm {
margin-top: 15px;
}
.cp-register-notes {
ul.cp-notes-list {

View File

@ -0,0 +1,48 @@
@import (reference) "../include/infopages.less";
@import (reference) "../include/colortheme-all.less";
@import (reference) "../include/alertify.less";
@import (reference) "../include/forms.less";
@import (reference) "../include/login.less";
&.cp-page-ssoauth {
.infopages_main();
.forms_main();
.alertify_main();
.form-group {
.extra {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
}
div.cp-ssoauth-pw {
display: none;
width: 100%;
}
.cp-container {
.login_main();
}
&.cp-register {
.cp-islogin {
display: none;
}
#passwordconfirm {
margin-bottom: 0px !important;
}
}
&.cp-login {
.cp-isregister {
display: none;
}
#password {
margin-bottom: 0px !important;
}
}
}

View File

@ -69,6 +69,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

@ -13,6 +13,9 @@ const Core = require("./commands/core");
const Metadata = require("./commands/metadata");
const Meta = require("./metadata");
const Logger = require("./log");
const plugins = require("./plugin-manager");
let SSOUtils = plugins.SSO && plugins.SSO.utils;
const Path = require("path");
const Fse = require("fs-extra");
@ -200,6 +203,12 @@ COMMANDS.start = (edPublic, blockId, reason) => {
}
Log.info('MODERATION_ACCOUNT_BLOCK', safeKey, waitFor());
}));
if (!SSOUtils) { return; }
SSOUtils.deleteAccount(Env, blockId, waitFor((err) => {
if (err) {
return Log.error('MODERATION_ACCOUNT_BLOCK_SSO', err, waitFor());
}
}));
}));
}).nThen((waitFor) => {
var report = {
@ -289,6 +298,12 @@ COMMANDS.restore = (edPublic) => {
}
Log.info('MODERATION_ACCOUNT_BLOCK_RESTORE', safeKey, waitFor());
}));
if (!SSOUtils) { return; }
SSOUtils.restoreAccount(Env, blockId, waitFor(function (err) {
if (err) {
return Log.error('MODERATION_ACCOUNT_BLOCK_RESTORE_SSO', err, waitFor());
}
}));
}));
}).nThen((waitFor) => {
deleteReport(Env, safeKey, waitFor((err) => {

View File

@ -5,6 +5,7 @@
const Block = require("../commands/block");
const MFA = require("../storage/mfa");
const Util = require("../common-util");
const Sessions = require("../storage/sessions");
const Commands = module.exports;
@ -55,8 +56,16 @@ const writeBlock = Commands.WRITE_BLOCK = function (Env, body, cb) {
};
writeBlock.complete = function (Env, body, cb) {
const { content } = body;
Block.writeLoginBlock(Env, content, cb);
const { publicKey, content, session } = body;
Block.writeLoginBlock(Env, content, (err) => {
if (err) { return void cb(err); }
if (!session) { return void cb(); }
const proof = Util.tryParse(content.registrationProof);
const oldKey = proof && proof[0];
Sessions.update(Env, publicKey, oldKey, session, "", cb);
});
};
// Remove a login block IFF

View File

@ -12,6 +12,7 @@ const MFA = require("../storage/mfa");
const Sessions = require("../storage/sessions");
const BlockStore = require("../storage/block");
const Block = require("../commands/block");
const config = require("../load-config");
const Commands = module.exports;
@ -61,21 +62,40 @@ var decode32 = S => {
};
// Decide expire time
// Allow user settings?
var EXPIRATION = 7 * 24 * 3600 * 1000; // Sessions are valid 7 days
var EXPIRATION = (config.otpSessionExpiration || 7 * 24) * 3600 * 1000;
// Create a session with a token for the given public key
const makeSession = (Env, publicKey, cb) => {
const sessionId = Sessions.randomId();
const makeSession = (Env, publicKey, oldKey, ssoSession, cb) => {
const sessionId = ssoSession || Sessions.randomId();
let SSOUtils = Env.plugins && Env.plugins.SSO && Env.plugins.SSO.utils;
// For password change, we need to get the sso session associated to the old block key
// In other cases (login and totp_setup), the sso session is associated to the current block
oldKey = oldKey || publicKey; // use the current block if no old key
let isUpdate = false;
nThen(function (w) {
if (!ssoSession || !SSOUtils) { return; }
// If we have an session token, confirm this is an sso account
SSOUtils.readBlock(Env, oldKey, w((err) => {
if (err === 'ENOENT') { return; } // No sso block, no need to update the session
if (err) {
w.abort();
return void cb('TOTP_VALIDATE_READ_SSO');
}
// We have an existing session for an SSO account: update the existing session
isUpdate = true;
}));
}).nThen(function (w) {
// store the token
Sessions.write(Env, publicKey, sessionId, JSON.stringify({
let sessionData = {
mfa: {
type: 'otp',
exp: (+new Date()) + EXPIRATION
}
}), w(function (err) {
};
var then = w(function (err) {
if (err) {
Env.Log.error("TOTP_VALIDATE_SESSION_WRITE", {
error: Util.serializeError(err),
@ -86,7 +106,12 @@ const makeSession = (Env, publicKey, cb) => {
return void cb("SESSION_WRITE_ERROR");
}
// else continue
}));
});
if (isUpdate) {
Sessions.update(Env, publicKey, oldKey, sessionId, JSON.stringify(sessionData), then);
} else {
Sessions.write(Env, publicKey, sessionId, JSON.stringify(sessionData), then);
}
}).nThen(function () {
cb(void 0, {
bearer: sessionId,
@ -207,7 +232,7 @@ const TOTP_SETUP = Commands.TOTP_SETUP = function (Env, body, cb) {
// There's still a little bit more to do and it could still fail.
TOTP_SETUP.complete = function (Env, body, cb) {
// the OTP code should have already been validated
var { publicKey, secret, contact } = body;
var { publicKey, secret, contact, session } = body;
// the device from which they configure MFA settings
// is assumed to be safe, so we'll respond with a JWT token
@ -257,7 +282,7 @@ TOTP_SETUP.complete = function (Env, body, cb) {
// we have already stored the MFA data, which will cause access to the resource to be restricted to the provided TOTP secret.
// we attempt to create a session as a matter of convenience - so if it fails
// that just means they'll be forced to authenticate
makeSession(Env, publicKey, cb);
makeSession(Env, publicKey, null, session, cb);
});
};
@ -305,12 +330,12 @@ So, we should:
2. send them the token
*/
var { publicKey } = body;
makeSession(Env, publicKey, cb);
var { publicKey, session } = body;
makeSession(Env, publicKey, null, session, cb);
};
// 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'); }
@ -436,7 +461,8 @@ const writeBlock = Commands.TOTP_WRITE_BLOCK = function (Env, body, cb) {
writeBlock.complete = function (Env, body, cb) {
const { publicKey, content } = body;
const { publicKey, content, session } = body;
let oldKey;
nThen(function (w) {
// Write new block
Block.writeLoginBlock(Env, content, w((err) => {
@ -448,7 +474,7 @@ writeBlock.complete = function (Env, body, cb) {
}).nThen(function (w) {
// Copy MFA settings
const proof = Util.tryParse(content.registrationProof);
const oldKey = proof && proof[0];
oldKey = proof && proof[0];
if (!oldKey) {
w.abort();
return void cb('INVALID_ANCESTOR');
@ -456,7 +482,7 @@ writeBlock.complete = function (Env, body, cb) {
MFA.copy(Env, oldKey, publicKey, w());
}).nThen(function () {
// Create a session for the current user
makeSession(Env, publicKey, cb);
makeSession(Env, publicKey, oldKey, session, cb);
});
};

View File

@ -726,6 +726,8 @@ var archiveBlock = function (Env, Server, cb, data) {
});
cb(err);
});
let SSOUtils = Env.plugins && Env.plugins.SSO && Env.plugins.SSO.utils;
if (SSOUtils) { SSOUtils.deleteAccount(Env, key, () => {}); }
};
var restoreArchivedBlock = function (Env, Server, cb, data) {
@ -740,6 +742,11 @@ var restoreArchivedBlock = function (Env, Server, cb, data) {
key: key,
reason: reason || '',
});
// Also restore SSO data
let SSOUtils = Env.plugins && Env.plugins.SSO && Env.plugins.SSO.utils;
if (SSOUtils) { SSOUtils.restoreAccount(Env, key, () => {}); }
cb(err);
});
};

View File

@ -186,5 +186,20 @@ Block.removeLoginBlock = function (Env, publicKey, reason, _cb) {
});
cb(err);
});
// We should also try to remove the SSO data. Errors will be logged
// but they don't have to be shown to the user. The account data
// is already deleted anyway.
// If this is NOT a password change, also delete sso user.
let SSOUtils = Env.plugins && Env.plugins.SSO && Env.plugins.SSO.utils;
if (!SSOUtils) { return; }
if (reason !== 'PASSWORD_CHANGE') {
SSOUtils.deleteAccount(Env, publicKey, () => {});
} else {
SSOUtils.deleteBlock(Env, publicKey, () => {});
}
};

View File

@ -18,6 +18,8 @@ const Package = require("../package.json");
const Default = require("./defaults");
const Path = require("path");
const plugins = require('./plugin-manager');
const Nacl = require("tweetnacl/nacl-fast");
var canonicalizeOrigin = function (s) {
@ -83,6 +85,7 @@ module.exports.create = function (config) {
const curve = Nacl.box.keyPair();
const Env = {
plugins: plugins,
logFeedback: Boolean(config.logFeedback),
mainPages: config.mainPages || Default.mainPages(),
@ -227,6 +230,8 @@ module.exports.create = function (config) {
evictionReport: {},
commandTimers: {},
sso: config.sso,
// initialized as undefined
bearerSecret: void 0,
curvePrivate: curve.secretKey,

View File

@ -4,6 +4,7 @@
var Nacl = require("tweetnacl/nacl-fast");
var Util = require('./common-util.js');
const plugins = require("./plugin-manager");
var Challenge = require("./storage/challenge.js");
// C.read(Env, id, cb)
@ -66,17 +67,26 @@ var COMMANDS = {};
// and to authenticate new sessions once a TOTP secret has been associated with their account,
const NOAUTH = require("./challenge-commands/base.js");
COMMANDS.MFA_CHECK = NOAUTH.MFA_CHECK;
COMMANDS.WRITE_BLOCK = NOAUTH.WRITE_BLOCK;
COMMANDS.WRITE_BLOCK = NOAUTH.WRITE_BLOCK; // Account creation + password change
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_WRITE_BLOCK = TOTP.TOTP_WRITE_BLOCK; // Password change only for now (v5.5.0)
COMMANDS.TOTP_REMOVE_BLOCK = TOTP.TOTP_REMOVE_BLOCK;
try {
// SSO plugin may not be installed
const SSO = plugins.SSO && plugins.SSO.challenge;
COMMANDS.SSO_AUTH = SSO.SSO_AUTH;
COMMANDS.SSO_AUTH_CB = SSO.SSO_AUTH_CB;
COMMANDS.SSO_WRITE_BLOCK = SSO.SSO_WRITE_BLOCK; // Account creation only
COMMANDS.SSO_UPDATE_BLOCK = SSO.SSO_UPDATE_BLOCK; // Password change
COMMANDS.SSO_VALIDATE = SSO.SSO_VALIDATE;
} catch (e) {}
var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-');
@ -145,7 +155,7 @@ var handleCommand = function (Env, req, res) {
date: date,
});
});
});
}, req);
} catch (err) {
Env.Log.error("CHALLENGE_COMMAND_THROWN_ERROR", {
error: Util.serializeError(err),
@ -295,7 +305,7 @@ var handleResponse = function (Env, req, res) {
});
}
res.status(200).json(content);
});
}, req, res);
});
};

View File

@ -13,13 +13,19 @@ const Logger = require("./log");
const AuthCommands = require("./http-commands");
const MFA = require("./storage/mfa");
const Sessions = require("./storage/sessions");
const cookieParser = require("cookie-parser");
const bodyParser = require('body-parser');
const BlobStore = require("./storage/blob");
const BlockStore = require("./storage/block");
const plugins = require("./plugin-manager");
const DEFAULT_QUERY_TIMEOUT = 5000;
const PID = process.pid;
let SSOUtils = plugins.SSO && plugins.SSO.utils;
var Env = JSON.parse(process.env.Env);
Env.plugins = plugins;
const response = Util.response(function (errLabel, info) {
if (!Env.Log) { return; }
Env.Log.error(errLabel, info);
@ -64,6 +70,7 @@ EVENTS.ENV_UPDATE = function (data /*, cb */) {
try {
Env = JSON.parse(data);
Env.Log = Log;
Env.plugins = plugins;
Env.incrementBytesWritten = function () {};
} catch (err) {
Log.error('HTTP_WORKER_ENV_UPDATE', Util.serializeError(err));
@ -165,6 +172,11 @@ const Express = require("express");
Express.static.mime.define({'application/wasm': ['wasm']});
var app = Express();
app.use(bodyParser.urlencoded({
extended: true
}));
app.use(cookieParser());
(function () {
if (!Env.logFeedback) { return; }
@ -200,6 +212,30 @@ const wsProxy = createProxyMiddleware({
app.use('/cryptpad_websocket', wsProxy);
app.use('/ssoauth', (req, res, next) => {
if (SSOUtils && req && req.body && req.body.SAMLResponse) {
req.method = 'GET';
let token = Util.uid();
let smres = req.body.SAMLResponse;
return SSOUtils.writeRequest(Env, {
id: token,
type: 'saml',
content: smres
}, (err) => {
if (err) {
Log.error('E_SSO_WRITE_REQ', err);
return res.sendStatus(500);
}
let value = `samltoken="${token}"; SameSite=Strict; HttpOnly`;
res.setHeader('Set-Cookie', value);
next();
});
}
next();
});
app.use('/blob', function (req, res, next) {
/* Head requests are used to check the size of a blob.
@ -265,6 +301,7 @@ app.use(function (req, res, next) {
next();
});
// serve custom app content from the customize directory
// useful for testing pages customized with opengraph data
app.use(Express.static(Path.resolve('./customize/www')));
@ -303,7 +340,7 @@ app.use('/block/', function (req, res, next) {
var authorization = req.headers.authorization;
var mfa_params;
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) {
@ -312,8 +349,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
@ -344,10 +380,31 @@ app.use('/block/', function (req, res, next) {
});
}
}));
// Same for SSO settings
if (!SSOUtils) { return; }
SSOUtils.readBlock(Env, name, w(function (err, content) {
if (err && (err.code === 'ENOENT' || err === '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.
@ -359,18 +416,19 @@ app.use('/block/', function (req, res, next) {
var no = function () {
w.abort();
res.status(401).json({
method: mfa_params.method,
sso: Boolean(sso_params),
method: mfa_params && mfa_params.method,
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(); }
@ -379,14 +437,18 @@ app.use('/block/', function (req, res, next) {
if (err) {
Log.error('SESSION_READ_ERROR', err);
return res.status(401).json({
method: mfa_params.method,
sso: Boolean(sso_params),
method: mfa_params && mfa_params.method,
code: 401,
});
}
let content = Util.tryParse(contentStr);
if (content.mfa && content.mfa.exp && ((+new Date()) > content.mfa.exp)) {
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", content.mfa);
Sessions.delete(Env, name, token, function (err) {
if (err) {
@ -398,12 +460,20 @@ 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
if (content.sso && content.sso.exp && (+new Date()) > content.sso.exp) {
Log.error("SSO_SESSION_EXPIRED", content.sso);
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();
}
// 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();
@ -481,6 +551,13 @@ var makeRouteCache = function (template, cacheName) {
};
};
const ssoList = Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) &&
Env.sso.list.map(function (obj) { return obj.name; }) || [];
const ssoCfg = (SSOUtils && ssoList.length) ? {
force: (Env.sso && Env.sso.enforced && 1) || 0,
password: (Env.sso && Env.sso.cpPassword && (Env.sso.forceCpPassword ? 2 : 1)) || 0,
list: ssoList
} : false;
var serveConfig = makeRouteCache(function () {
return [
'define(function(){',
@ -507,6 +584,7 @@ var serveConfig = makeRouteCache(function () {
shouldUpdateNode: Env.shouldUpdateNode || undefined,
listMyInstance: Env.listMyInstance,
accounts_api: Env.accounts_api,
sso: ssoCfg
}, null, '\t'),
'});'
].join(';\n');

View File

@ -46,5 +46,12 @@ if (!isPositiveNumber(config.premiumUploadSize) || config.premiumUploadSize < co
delete config.premiumUploadSize;
}
config.sso = {};
try {
config.sso = require("../config/sso");
} catch (e) {
console.log("SSO config not found");
}
module.exports = config;

18
lib/plugin-manager.js Normal file
View File

@ -0,0 +1,18 @@
const fs = require('node:fs');
const plugins = {};
try {
let pluginsDir = fs.readdirSync(__dirname + '/plugins');
pluginsDir.forEach((name) => {
try {
let plugin = require(`./plugins/${name}/index`);
plugins[plugin.name] = plugin.modules;
} catch (err) {
console.error(err);
}
});
} catch (err) {
if (err.code !== 'ENOENT') { console.error(err); }
}
module.exports = plugins;

View File

@ -25,6 +25,7 @@ Feel free to migrate all of these to a relational DB at some point in the future
const Basic = module.exports;
const Fs = require("node:fs");
const Fse = require("fs-extra");
const Path = require("node:path");
var pathError = (cb) => {
@ -70,4 +71,17 @@ Basic.deleteDir = function (Env, path, cb) {
Fs.rm(path, { recursive: true, force: true }, cb);
};
Basic.archive = function (Env, path, archivePath, cb) {
Fse.move(path, archivePath, {
overwrite: true,
}, (err) => {
cb(err);
});
};
Basic.restore = function (Env, archivePath, path, cb) {
Fse.move(archivePath, path, {
//overwrite: true,
}, (err) => {
cb(err);
});
};

View File

@ -46,6 +46,19 @@ Sessions.delete = function (Env, id, ref, cb) {
Basic.delete(Env, path, cb);
};
Sessions.update = function (Env, id, oldId, ref, dataStr, cb) {
var data = Util.tryParse(dataStr);
Sessions.read(Env, oldId, ref, (err, oldData) => {
let content = Util.tryParse(oldData) || {};
Object.keys(data || {}).forEach((type) => {
content[type] = data[type];
});
Sessions.delete(Env, oldId, ref, () => {
Sessions.write(Env, id, ref, JSON.stringify(content), cb);
});
});
};
Sessions.deleteUser = function (Env, id, cb) {
if (!id || typeof(id) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);

106
lib/storage/sso.js Normal file
View File

@ -0,0 +1,106 @@
const Basic = require("./basic");
const Path = require("node:path");
const Util = require("../common-util");
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
A second part (sso-users) is a database of accounts registered via SSO (SSO id ==> block seed)
A third part (sso-blocks) is a database 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.
*/
var pathFromId = function (Env, id, subPath) {
if (!id || typeof(id) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
return Path.join(Env.paths.base, subPath, id.slice(0, 2), `${id}.json`);
};
var reqPathFromId = function (Env, id) {
return pathFromId(Env, id, 'sso_request');
};
var blockPathFromId = function (Env, id) {
return pathFromId(Env, id, 'sso_block');
};
var userPathFromId = function (Env, id, provider) {
if (!id || typeof(id) !== 'string') { return; }
if (!provider || typeof(provider) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
return Path.join(Env.paths.base, 'sso_user', provider, id.slice(0, 2), `${id}.json`);
};
var blockArchivePath = function (Env, id) {
return Path.join(Env.paths.archive, 'sso_block', id.slice(0, 2), `${id}.json`);
};
var userArchivePath = function (Env, id, provider) {
return Path.join(Env.paths.archive, 'sso_user', provider, id.slice(0, 2), `${id}.json`);
};
const Req = SSO.request = {};
Req.read = function (Env, id, cb) {
var path = reqPathFromId(Env, id);
Basic.read(Env, path, cb);
};
Req.write = function (Env, id, data, cb) {
var path = reqPathFromId(Env, id);
Basic.write(Env, path, data, cb);
};
Req.delete = function (Env, id, cb) {
var path = reqPathFromId(Env, id);
Basic.delete(Env, path, cb);
};
const User = SSO.user = {};
User.read = function (Env, provider, id, cb) {
var path = userPathFromId(Env, id, provider);
Basic.read(Env, path, cb);
};
User.write = function (Env, provider, id, data, cb) {
var path = userPathFromId(Env, id, provider);
Basic.write(Env, path, data, cb);
};
User.delete = function (Env, provider, id, cb) {
var path = userPathFromId(Env, id, provider);
Basic.delete(Env, path, cb);
};
User.archive = function (Env, provider, id, cb) {
var path = userPathFromId(Env, id, provider);
var archivePath = userArchivePath(Env, id, provider);
Basic.archive(Env, path, archivePath, cb);
};
User.restore = function (Env, provider, id, cb) {
var path = userPathFromId(Env, id, provider);
var archivePath = userArchivePath(Env, id, provider);
Basic.restore(Env, archivePath, 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);
};
Block.archive = function (Env, id, cb) {
var path = blockPathFromId(Env, id);
var archivePath = blockArchivePath(Env, id);
Basic.archive(Env, path, archivePath, cb);
};
Block.restore = function (Env, id, cb) {
var path = blockPathFromId(Env, id);
var archivePath = blockArchivePath(Env, id);
Basic.restore(Env, archivePath, path, cb);
};

363
package-lock.json generated
View File

@ -10,7 +10,9 @@
"license": "AGPL-3.0+",
"dependencies": {
"@mcrowe/minibloom": "^0.2.0",
"@node-saml/node-saml": "^4.0.5",
"alertify.js": "1.0.11",
"body-parser": "^1.20.2",
"bootstrap": "^4.0.0",
"bootstrap-tokenfield": "^0.12.0",
"chainpad": "^5.2.6",
@ -21,6 +23,7 @@
"ckeditor": "npm:ckeditor4@~4.22.1",
"codemirror": "^5.19.0",
"components-font-awesome": "^4.6.3",
"cookie-parser": "^1.4.6",
"croppie": "^2.5.0",
"dragula": "3.7.2",
"drawio": "cryptpad/drawio-npm#npm",
@ -42,6 +45,7 @@
"notp": "^2.0.3",
"nthen": "0.1.8",
"open-sans-fontface": "^1.4.0",
"openid-client": "^5.4.2",
"pako": "^2.1.0",
"prompt-confirm": "^2.0.4",
"pull-stream": "^3.6.1",
@ -86,6 +90,48 @@
"node": ">=4"
}
},
"node_modules/@node-saml/node-saml": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-4.0.5.tgz",
"integrity": "sha512-J5DglElbY1tjOuaR1NPtjOXkXY5bpUhDoKVoeucYN98A3w4fwgjIOPqIGcb6cQsqFq2zZ6vTCeKn5C/hvefSaw==",
"dependencies": {
"@types/debug": "^4.1.7",
"@types/passport": "^1.0.11",
"@types/xml-crypto": "^1.4.2",
"@types/xml-encryption": "^1.2.1",
"@types/xml2js": "^0.4.11",
"@xmldom/xmldom": "^0.8.6",
"debug": "^4.3.4",
"xml-crypto": "^3.0.1",
"xml-encryption": "^3.0.2",
"xml2js": "^0.5.0",
"xmlbuilder": "^15.1.1"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@node-saml/node-saml/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@node-saml/node-saml/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/@nodelib/fs.stat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz",
@ -95,6 +141,53 @@
"node": ">= 6"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.4",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.4.tgz",
"integrity": "sha512-N7UDG0/xiPQa2D/XrVJXjkWbpqHCd2sBaB32ggRF2l83RhPfamgKGF8gwwqyksS95qUS5ZYF9aF+lLPRlwI2UA==",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.37",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.37.tgz",
"integrity": "sha512-zBUSRqkfZ59OcwXon4HVxhx5oWCJmc0OtBTK05M+p0dYjgN6iTwIL2T/WbsQZrEsdnwaF9cWQ+azOnpPvIqY3Q==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.10.tgz",
"integrity": "sha512-tOSCru6s732pofZ+sMv9o4o3Zc+Sa8l3bxd/tweTQudFn06vAzb13ZX46Zi6m6EJ+RUbRTHvgQJ1gBtSgkaUYA==",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.20.tgz",
"integrity": "sha512-rOaqlkgEvOW495xErXMsmyX3WKBInbhG5eqojXYi3cGUaLoRDlXa5d52fkfWZT963AZ3v2eZ4MbKE6WpDAGVsw==",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.17.38",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.38.tgz",
"integrity": "sha512-hXOtc0tuDHZPFwwhuBJXPbjemWtXnJjbvuuyNH2Y5Z6in+iXc63c4eXYDc7GGGqHy+iwYqAJMdaItqdnbcBKmg==",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz",
@ -105,6 +198,11 @@
"@types/node": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.3.tgz",
"integrity": "sha512-pP0P/9BnCj1OVvQR2lF41EkDG/lWWnDyA203b/4Fmi2eTyORnBtcDoKDwjWQthELrBvWkMOrvSOnZ8OVlW6tXA=="
},
"node_modules/@types/http-proxy": {
"version": "1.17.12",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.12.tgz",
@ -113,17 +211,89 @@
"@types/node": "*"
}
},
"node_modules/@types/mime": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.3.tgz",
"integrity": "sha512-Ys+/St+2VF4+xuY6+kDIXGxbNRO0mesVg0bbxEfB97Od1Vjpjx9KD1qxs64Gcb3CWPirk9Xe+PT4YiiHQ9T+eg=="
},
"node_modules/@types/minimatch": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
"integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==",
"dev": true
},
"node_modules/@types/ms": {
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.33.tgz",
"integrity": "sha512-AuHIyzR5Hea7ij0P9q7vx7xu4z0C28ucwjAZC0ja7JhINyCnOw8/DnvAPQQ9TfOlCtZAmCERKQX9+o1mgQhuOQ=="
},
"node_modules/@types/node": {
"version": "20.6.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.3.tgz",
"integrity": "sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA=="
},
"node_modules/@types/passport": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.14.tgz",
"integrity": "sha512-D6p2ygR2S7Cq5PO7iUaEIQu/5WrM0tONu6Lxgk0C9r3lafQIlVpWCo3V/KI9To3OqHBxcfQaOeK+8AvwW5RYmw==",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/qs": {
"version": "6.9.9",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.9.tgz",
"integrity": "sha512-wYLxw35euwqGvTDx6zfY1vokBFnsK0HNrzc6xNHchxfO2hpuRg74GbkEW7e3sSmPvj0TjCDT1VCa6OtHXnubsg=="
},
"node_modules/@types/range-parser": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.6.tgz",
"integrity": "sha512-+0autS93xyXizIYiyL02FCY8N+KkKPhILhcUSA276HxzreZ16kl+cmwvV2qAM/PuCCwPXzOXOWhiPcw20uSFcA=="
},
"node_modules/@types/send": {
"version": "0.17.2",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.2.tgz",
"integrity": "sha512-aAG6yRf6r0wQ29bkS+x97BIs64ZLxeE/ARwyS6wrldMm3C1MdKwCcnnEwMC1slI8wuxJOpiUH9MioC0A0i+GJw==",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.3.tgz",
"integrity": "sha512-yVRvFsEMrv7s0lGhzrggJjNOSmZCdgCjw9xWrPr/kNNLp6FaDfMC1KaYl3TSJ0c58bECwNBMoQrZJ8hA8E1eFg==",
"dependencies": {
"@types/http-errors": "*",
"@types/mime": "*",
"@types/node": "*"
}
},
"node_modules/@types/xml-crypto": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@types/xml-crypto/-/xml-crypto-1.4.3.tgz",
"integrity": "sha512-pnvKYb7vUsUIMc+C6JM/j779YWQgOMcwjnqHJ9cdaWXwWEBE1hAqthzeszRx62V5RWMvS+XS9w9tXMOYyUc8zg==",
"dependencies": {
"@types/node": "*",
"xpath": "0.0.27"
}
},
"node_modules/@types/xml-encryption": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.2.tgz",
"integrity": "sha512-UeuYOqW3ZzUQfwb/mb3GNZ2/DlVdh5mjJNmB/yFXgQr8/pwlVJ9I2w+AHPfRDzLshe7YpgUB4T1//qgbk6U87Q==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/xml2js": {
"version": "0.4.12",
"resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.12.tgz",
"integrity": "sha512-CZPpQKBZ8db66EP5hCjwvYrLThgZvnyZrPXK2W+UI1oOaWezGt34iOaUCX4Jah2X8+rQqjvl9VKEIT8TR1I0rA==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
@ -681,12 +851,12 @@
}
},
"node_modules/body-parser": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
"integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==",
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
"integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.4",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
@ -694,7 +864,7 @@
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.11.0",
"raw-body": "2.5.1",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
@ -1117,6 +1287,26 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
"dependencies": {
"cookie": "0.4.1",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@ -1564,6 +1754,43 @@
"node": ">= 0.10.0"
}
},
"node_modules/express/node_modules/body-parser": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
"integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.4",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.11.0",
"raw-body": "2.5.1",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/express/node_modules/raw-body": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
"integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
@ -2436,6 +2663,14 @@
"node": ">=0.10.0"
}
},
"node_modules/jose": {
"version": "4.15.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.3.tgz",
"integrity": "sha512-RZJdL9Qjd1sqNdyiVteRGV/bnWtik/+PJh1JP4kT6+x1QQMn+7ryueRys5BEueuayvSVY8CWGCisCDazeRLTuw==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jquery": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz",
@ -3182,6 +3417,14 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz",
@ -3213,6 +3456,14 @@
"node": ">=0.10.0"
}
},
"node_modules/oidc-token-hash": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz",
"integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -3238,6 +3489,20 @@
"resolved": "https://registry.npmjs.org/open-sans-fontface/-/open-sans-fontface-1.4.0.tgz",
"integrity": "sha512-d1VXrt1qPScsZnDHbZTOf1SmUnanr3KQgQM6+ye6KoFgrLo8a8mkX/J/ZJ2+w7vf0sCC02lRia5SAiaz0JPEog=="
},
"node_modules/openid-client": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz",
"integrity": "sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==",
"dependencies": {
"jose": "^4.15.1",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/optimist": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
@ -3649,9 +3914,9 @@
}
},
"node_modules/raw-body": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
"integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
@ -3879,6 +4144,11 @@
"resolved": "https://registry.npmjs.org/saferphore/-/saferphore-0.0.1.tgz",
"integrity": "sha512-/KaXQyumYbALQwhl4/Qov6voayHwtBD12AWRI3zxYBW7cY3Mjtkk7PLttZPq+25nyfu/k3APHUptPLZI9A7yHA=="
},
"node_modules/sax": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz",
"integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA=="
},
"node_modules/scrypt-async": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/scrypt-async/-/scrypt-async-1.2.0.tgz",
@ -4809,6 +5079,83 @@
"node": "*"
}
},
"node_modules/xml-crypto": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-3.2.0.tgz",
"integrity": "sha512-qVurBUOQrmvlgmZqIVBqmb06TD2a/PpEUfFPgD7BuBfjmoH4zgkqaWSIJrnymlCvM2GGt9x+XtJFA+ttoAufqg==",
"dependencies": {
"@xmldom/xmldom": "^0.8.8",
"xpath": "0.0.32"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xml-crypto/node_modules/xpath": {
"version": "0.0.32",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
"integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/xml-encryption": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.0.2.tgz",
"integrity": "sha512-VxYXPvsWB01/aqVLd6ZMPWZ+qaj0aIdF+cStrVJMcFj3iymwZeI0ABzB3VqMYv48DkSpRhnrXqTUkR34j+UDyg==",
"dependencies": {
"@xmldom/xmldom": "^0.8.5",
"escape-html": "^1.0.3",
"xpath": "0.0.32"
},
"engines": {
"node": ">=12"
}
},
"node_modules/xml-encryption/node_modules/xpath": {
"version": "0.0.32",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
"integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xml2js/node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
"integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
"engines": {
"node": ">=8.0"
}
},
"node_modules/xpath": {
"version": "0.0.27",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.27.tgz",
"integrity": "sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ==",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

@ -15,6 +15,8 @@
"@mcrowe/minibloom": "^0.2.0",
"chainpad-crypto": "^0.2.5",
"chainpad-server": "^5.1.0",
"cookie-parser": "^1.4.6",
"body-parser": "^1.20.2",
"express": "~4.18.2",
"fs-extra": "^7.0.0",
"get-folder-size": "^2.0.1",
@ -23,6 +25,8 @@
"jsonwebtoken": "^9.0.0",
"notp": "^2.0.3",
"nthen": "0.1.8",
"openid-client": "^5.4.2",
"@node-saml/node-saml": "^4.0.5",
"prompt-confirm": "^2.0.4",
"pull-stream": "^3.6.1",
"saferphore": "0.0.1",

View File

@ -10,6 +10,7 @@ define(['/customize/application_config.js'], function (AppConfig) {
blockHashKey: 'Block_hash',
fileHashKey: 'FS_hash',
sessionJWT: 'Session_JWT',
ssoSeed: 'SSO_seed',
// Store
displayNameKey: 'cryptpad.username',

549
www/common/common-login.js Normal file
View File

@ -0,0 +1,549 @@
define([
'chainpad-listmap',
'/components/chainpad-crypto/crypto.js',
'/common/common-util.js',
'/common/outer/network-config.js',
'/common/common-credential.js',
'/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',
'/components/nthen/index.js',
'/common/outer/login-block.js',
'/common/common-hash.js',
'/common/outer/http-command.js',
'/components/tweetnacl/nacl-fast.min.js',
'/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 cb = Util.once(Util.mkAsync(_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('error', function (info) {
cb(info.type, {reason: info.message});
})
.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,
auth_token: {}
};
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;
if (ssoAuth && ssoAuth.name) { uname = res.uname = ssoAuth.name; }
}));
}).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, ssoSession, cb) {
onOTP(function (code) {
ServerCommand(res.opt.blockKeys.sign, {
command: 'TOTP_VALIDATE',
code: code,
session: ssoSession
// 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;
var missingSSO;
nThen(function (w) {
Util.getBlock(blockUrl, {
// request the block without credentials
}, w(function (err, response) {
if (err === 401) {
missingSSO = response && response.sso;
missingAuth = response && response.method;
return void console.log("Block requires 2FA");
}
if (err === 404 && response && response.reason) {
waitFor.abort();
w.abort();
/*
// the following block prevent users from re-using an old password
if (isRegister) { return void cb('HAS_PLACEHOLDER'); }
*/
return void cb('DELETED_USER', response);
}
// 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 (!missingSSO) { return; }
// SSO session should always be applied before the OTP one
// because we can't transform an account into an SSO account later
// so we probably don't need to recover the OTP session here
ServerCommand(res.opt.blockKeys.sign, {
command: 'SSO_VALIDATE',
jwt: ssoAuth.data,
}, w(function (err, response) {
if (err) {
console.error(err);
w.abort();
waitFor.abort();
return void cb(err);
}
res.auth_token = response;
}));
}).nThen(function (w) {
if (missingAuth !== 'TOTP') { return; }
// 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--;
// If we have an SSO account, provide the SSO session to update it with OTP
var ssoSession = (res.auth_token && res.auth_token.bearer) || '';
TOTP_prompt(tries !== 2, ssoSession, 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);
LocalStore.setSessionToken('');
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;
// FIXME We currently can't create an account with OTP by default
// NOTE If we ever want to do that for SSO accounts it will require major changes
// because writeLoginBlock only supports one type of authentication at a time
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

@ -2005,6 +2005,11 @@ define([
console.log("no block found");
return;
}
if (err && err === 401) {
// there is a protected block at the next location, abort FIXME check
waitFor.abort();
return void cb({ error: 'EEXISTS' });
}
response.arrayBuffer().then(waitFor(arraybuffer => {
var block = new Uint8Array(arraybuffer);
@ -2053,21 +2058,42 @@ define([
User_hash: newHash,
edPublic: edPublic,
};
var sessionToken = LocalStore.getSessionToken() || undefined;
Block.writeLoginBlock({
auth: auth,
blockKeys: blockKeys,
oldBlockKeys: oldBlockKeys,
content: content
content: content,
session: sessionToken // Recover existing SSO session
}, waitFor(function (err, data) {
if (err) {
waitFor.abort();
return void cb({error: err});
}
// Update the session if OTP is enabled
// If OTP is disabled, keep the existing SSO session
if (data && data.bearer) {
LocalStore.setSessionToken(data.bearer);
}
}));
}).nThen(function (waitFor) {
var isSSO = Boolean(LocalStore.getSSOSeed());
if (!isSSO) { return; }
// Update "sso_block" data for SSO accounts
Block.updateSSOBlock({
blockKeys: blockKeys,
oldBlockKeys: oldBlockKeys
}, waitFor(function (err) {
if (err) {
// If we can't move the sso_block data, we won't be able to log in later
// so we must abort the password change.
console.error(err);
waitFor.abort();
return void cb({error: err});
}
}));
}).nThen(function (waitFor) {
var blockUrl = Block.getBlockUrl(blockKeys);
var sessionToken = LocalStore.getSessionToken() || undefined;

View File

@ -886,6 +886,7 @@ define([
}));
}).nThen(function (waitFor) {
// Delete Drive
store.ownDeletion = true;
Store.removeOwnedChannel(clientId, {
channel: store.driveChannel,
force: true
@ -3059,6 +3060,7 @@ define([
})
.on('error', function (info) {
if (info.error && info.error === 'EDELETED') {
if (store.ownDeletion) { return; }
broadcast([], "LOGOUT", {
reason: info.message
});

View File

@ -88,6 +88,13 @@ define([
safeSet(Constants.sessionJWT, token);
};
LocalStore.getSSOSeed = function () {
return localStorage[Constants.ssoSeed];
};
LocalStore.setSSOSeed = function (seed) {
safeSet(Constants.ssoSeed, seed);
};
LocalStore.getAccountName = function () {
return localStorage[Constants.userNameKey];
};
@ -134,6 +141,7 @@ define([
Constants.userHashKey,
Constants.blockHashKey,
Constants.sessionJWT,
Constants.ssoSeed,
'loginToken',
'plan',
].forEach(function (k) {

View File

@ -169,9 +169,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,
@ -179,29 +177,27 @@ define([
}, cb);
};
Block.writeLoginBlock = function (data, cb) {
const { content, blockKeys, oldBlockKeys, auth } = data;
const { content, blockKeys, oldBlockKeys, auth, pw, session } = 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, {
command: command,
content: block
content: block,
session: session // sso session
}, cb);
};
Block.removeLoginBlock = function (data, cb) {
const { reason, 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,
@ -210,5 +206,16 @@ define([
}, cb);
};
Block.updateSSOBlock = function (data, cb) {
const { blockKeys, oldBlockKeys } = data;
var oldProof = oldBlockKeys && Block.proveAncestor(oldBlockKeys);
ServerCommand(blockKeys.sign, {
command: 'SSO_UPDATE_BLOCK',
ancestorProof: oldProof
}, cb);
};
return Block;
});

View File

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

@ -3,7 +3,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
define([
'/api/config',
'jquery',
'/common/hyperscript.js',
'/common/cryptpad-common.js',
'/customize/login.js',
'/common/common-interface.js',
@ -13,7 +15,7 @@ define([
//'/common/test.js',
'css!/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');
@ -23,6 +25,31 @@ 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);
// Disable bfcache (back/forward cache) to prevent SSO button
// being disabled when using the browser "back" feature on the SSO page
$(window).on('unload', () => {});
}
/* Log in UI */
// deferred execution to avoid unnecessary asset loading
var loginReady = function (cb) {

View File

@ -3,6 +3,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
define([
'/api/config',
'jquery',
'/customize/login.js',
'/common/cryptpad-common.js',
@ -18,7 +19,7 @@ define([
'/customize/pages.js',
'css!/components/components-font-awesome/css/font-awesome.min.css',
], function ($, Login, Cryptpad, /*Test,*/ Cred, UI, Util, Realtime, Constants, Feedback, LocalStore, h, Pages) {
], function (Config, $, Login, Cryptpad, /*Test,*/ Cred, UI, Util, Realtime, Constants, Feedback, LocalStore, h, Pages) {
if (window.top !== window) { return; }
var Messages = Cryptpad.Messages;
$(function () {
@ -53,6 +54,32 @@ define([
var I_REALLY_WANT_TO_USE_MY_EMAIL_FOR_MY_USERNAME = false;
var br = function () { return h('br'); };
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-register-sso');
var list = Config.sso.list.map(function (name) {
var b = h('button.btn.btn-secondary', name);
var $b = $(b).click(function () {
console.log('sso register click:', name);
$b.prop('disabled', 'disabled');
Login.ssoAuth(name, function (err, data) {
if (data.url) {
window.location.href = data.url;
}
});
});
return b;
});
$sso.append(list);
// Disable bfcache (back/forward cache) to prevent SSO button
// being disabled when using the browser "back" feature on the SSO page
$(window).on('unload', () => {});
}
var registerClick = function () {
var uname = $uname.val().trim();
// trim whitespace surrounding the username since it is otherwise included in key derivation
@ -130,7 +157,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

@ -582,10 +582,17 @@ define([
loadingText: Messages.settings_deleteTitle
});
setTimeout(function () {
var name = privateData.accountName;
var bytes;
var auth = {};
var ssoSeed;
nThen(function (w) {
sframeChan.query("Q_SETTINGS_GET_SSO_SEED", {
}, w(function (err, obj) {
if (!obj || !obj.seed) { return; } // Not an sso account?
ssoSeed = obj.seed;
}));
}).nThen(function (w) {
var name = ssoSeed || privateData.accountName;
deriveBytes(name, password, w(function (_bytes) {
bytes = _bytes;
}));
@ -660,6 +667,7 @@ define([
create['change-password'] = function() {
if (!common.isLoggedIn()) { return; }
if (privateData.isSSO && ApiConfig.sso && ApiConfig.sso.password === 0) { return; }
var $div = $('<div>', { 'class': 'cp-settings-change-password cp-sidebarlayout-element' });
@ -730,8 +738,15 @@ define([
setTimeout(function () {
var oldBytes, newBytes;
var auth = {};
var ssoSeed;
nThen(function (w) {
var name = privateData.accountName;
sframeChan.query("Q_SETTINGS_GET_SSO_SEED", {
}, w(function (err, obj) {
if (!obj || !obj.seed) { return; } // Not an sso account?
ssoSeed = obj.seed;
}));
}).nThen(function (w) {
var name = ssoSeed || privateData.accountName;
deriveBytes(name, oldPassword, w(function (bytes) {
oldBytes = bytes;
}));
@ -1061,6 +1076,7 @@ define([
var Base32, QRCode, Nacl;
var blockKeys;
var recoverySecret;
var ssoSeed;
nThen(function (waitFor) {
require([
'/auth/base32.js',
@ -1071,12 +1087,19 @@ define([
QRCode = window.QRCode;
Nacl = window.nacl;
}));
}).nThen(function (waitFor) {
sframeChan.query("Q_SETTINGS_GET_SSO_SEED", {
}, waitFor(function (err, obj) {
if (!obj || !obj.seed) { return; } // Not an sso account?
ssoSeed = obj.seed;
}));
}).nThen(function (waitFor) {
var next = waitFor();
// scrypt locks up the UI before the DOM has a chance
// to update (displaying logs, etc.), so do a set timeout
setTimeout(function () {
Login.Cred.deriveFromPassphrase(name, password, Login.requiredBytes, function (bytes) {
var salt = ssoSeed || name;
Login.Cred.deriveFromPassphrase(salt, password, Login.requiredBytes, function (bytes) {
var result = Login.allocateBytes(bytes);
sframeChan.query("Q_SETTINGS_CHECK_PASSWORD", {
blockHash: result.blockHash,
@ -1168,7 +1191,6 @@ define([
lock = true;
var data = {
command: 'TOTP_SETUP',
secret: secret,
contact: "secret:" + recoverySecret, // TODO other recovery options
code: code,

View File

@ -85,7 +85,10 @@ define([
require([
'/common/outer/http-command.js',
], function (ServerCommand) {
ServerCommand(obj.key, obj.data, function (err, response) {
var data = obj.data;
data.command = 'TOTP_SETUP';
data.session = Utils.LocalStore.getSessionToken();
ServerCommand(obj.key, data, function (err, response) {
cb({ success: Boolean(!err && response && response.bearer) });
if (response && response.bearer) {
Utils.LocalStore.setSessionToken(response.bearer);
@ -115,11 +118,18 @@ define([
Utils.Util.getBlock(parsed.href, {}, function (err, data) {
cb({
mfa: err === 401,
sso: data && data.sso,
type: data && data.method
});
});
});
});
sframeChan.on('Q_SETTINGS_GET_SSO_SEED', function (obj, _cb) {
var cb = Utils.Util.mkAsync(_cb);
cb({
seed: Utils.LocalStore.getSSOSeed()
});
});
sframeChan.on('Q_SETTINGS_REMOVE_OWNED_PADS', function (data, cb) {
Cryptpad.removeOwnedPads(data, cb);
});
@ -144,8 +154,9 @@ define([
category = window.location.hash.slice(1);
window.location.hash = '';
}
var addData = function (obj) {
var addData = function (obj, Cryptpad, user, Utils) {
if (category) { obj.category = category; }
obj.isSSO = Boolean(Utils.LocalStore.getSSOSeed());
};
SFCommonO.start({
noRealtime: true,

16
www/ssoauth/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<!-- If this file is not called customize.dist/src/template.html, it is generated -->
<head>
<title data-localization="main_title">CryptPad: Collaboration suite, encrypted and open-source</title>
<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 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="/components/requirejs/require.js?ver=2.3.5"></script>
</head>
<body class="html">
<noscript></noscript>

163
www/ssoauth/main.js Normal file
View File

@ -0,0 +1,163 @@
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',
'/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) {
UI.removeLoadingScreen();
var msg = Messages.error;
if (err === 'NO_SUCH_USER') { msg = Messages.drive_sfPasswordError; }
let $button = $('button#cp-ssoauth-button');
$button.prop('disabled', '');
return void UI.warn(msg);
}
LocalStore.setSSOSeed(seed.toLowerCase());
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();
$pw.focus();
$pw.on('keypress', (ev) => {
if (ev.which !== 13) { return; }
if (!$pw2.val() && data.register) {
return void $pw2.focus();
}
$button.click();
});
$pw2.on('keypress', (ev) => {
if (ev.which !== 13) { return; }
$button.click();
});
$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);
}
if (data.register && !pw && ApiConfig.sso.password === 2) {
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);
});
});
});
});