From 7d3f67cd864fee183d922fd5ffad86a22801f4a9 Mon Sep 17 00:00:00 2001 From: yflory Date: Thu, 26 Oct 2023 17:55:54 +0200 Subject: [PATCH] SSO + OTP account deletion and password change --- lib/archive-account.js | 11 +++++ lib/challenge-commands/base.js | 13 +++++- lib/challenge-commands/sso.js | 82 ++++++++++++++++++++++++++++++--- lib/challenge-commands/totp.js | 50 +++++++++++++++----- lib/commands/admin-rpc.js | 6 +++ lib/commands/block.js | 13 ++++++ lib/http-commands.js | 7 +-- lib/http-worker.js | 8 ++-- lib/sso-utils.js | 64 ++++++++++++++++++++++--- lib/storage/basic.js | 16 ++++++- lib/storage/sessions.js | 13 ++++++ lib/storage/sso.js | 29 +++++++++++- www/common/common-constants.js | 1 + www/common/common-login.js | 31 +++++++++---- www/common/cryptpad-common.js | 28 ++++++++++- www/common/outer/async-store.js | 2 + www/common/outer/local-store.js | 10 +++- www/common/outer/login-block.js | 16 ++++++- www/settings/inner.js | 29 ++++++++++-- www/settings/main.js | 12 ++++- www/ssoauth/main.js | 1 + 21 files changed, 389 insertions(+), 53 deletions(-) diff --git a/lib/archive-account.js b/lib/archive-account.js index b714bffed..56d90ba7f 100644 --- a/lib/archive-account.js +++ b/lib/archive-account.js @@ -9,6 +9,7 @@ const Core = require("./commands/core"); const Metadata = require("./commands/metadata"); const Meta = require("./metadata"); const Logger = require("./log"); +const SSOUtils = require('./sso-utils'); const Path = require("path"); const Fse = require("fs-extra"); @@ -196,6 +197,11 @@ COMMANDS.start = (edPublic, blockId, reason) => { } Log.info('MODERATION_ACCOUNT_BLOCK', safeKey, waitFor()); })); + SSOUtils.deleteAccount(Env, blockId, waitFor((err) => { + if (err) { + return Log.error('MODERATION_ACCOUNT_BLOCK_SSO', err, waitFor()); + } + })); })); }).nThen((waitFor) => { var report = { @@ -285,6 +291,11 @@ COMMANDS.restore = (edPublic) => { } Log.info('MODERATION_ACCOUNT_BLOCK_RESTORE', safeKey, waitFor()); })); + 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) => { diff --git a/lib/challenge-commands/base.js b/lib/challenge-commands/base.js index a1c55ff3f..d7fe3536b 100644 --- a/lib/challenge-commands/base.js +++ b/lib/challenge-commands/base.js @@ -1,6 +1,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; @@ -51,8 +52,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 diff --git a/lib/challenge-commands/sso.js b/lib/challenge-commands/sso.js index 72a324432..fe1dcf266 100644 --- a/lib/challenge-commands/sso.js +++ b/lib/challenge-commands/sso.js @@ -164,8 +164,8 @@ register.complete = function (Env, body, cb) { ssoUser = user; })); }).nThen((w) => { - const { sub } = payload; - SSOUtils.writeBlock(Env, publicKey, sub, w((err) => { + const { sub, provider } = payload; + SSOUtils.writeBlock(Env, publicKey, provider, sub, w((err) => { if (err) { w.abort(); return void cb('SSO_BLOCK_WRITE'); @@ -181,8 +181,10 @@ register.complete = function (Env, body, cb) { ssoUser.complete = true; SSOUtils.updateUser(Env, provider, sub, ssoUser, w()); }).nThen(() => { - const { data, provider } = payload; - SSOUtils.makeSession(Env, publicKey, provider, data, cb); + const { data, sub, provider } = payload; + let session = Util.clone(data); + session.id = sub; + SSOUtils.makeSession(Env, publicKey, provider, session, cb); }); }; @@ -228,8 +230,74 @@ login.complete = function (Env, body, cb) { } })); }).nThen(() => { - const { data, provider } = payload; - SSOUtils.makeSession(Env, publicKey, provider, data, cb); + const { data, sub, provider } = payload; + let session = Util.clone(data); + session.id = sub; + SSOUtils.makeSession(Env, publicKey, provider, session, cb); + }); +}; + +const update = Commands.SSO_UPDATE_BLOCK = function (Env, body, cb) { + const { publicKey, ancestorProof } = body; + + // they must provide a valid block public key + if (!Block.isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); } + + let oldKey; + nThen((w) => { + BlockStore.isAvailable(Env, publicKey, w((err, result) => { + if (err && !result) { + w.abort(); + return void cb(err); + } + // Block found: next + })); + }).nThen((w) => { + Block.validateAncestorProof(Env, ancestorProof, w((err, provenKey) => { + if (err) { + w.abort(); + return void cb(err); + } + // Ancestor found and proven + oldKey = provenKey; + })); + }).nThen(() => { + SSOUtils.readBlock(Env, oldKey, (err) => { + if (err) { + return void cb('INVALID_OLD_BLOCK'); + } + cb(); + }); + }); +}; +update.complete = function (Env, body, cb) { + const { publicKey, ancestorProof } = body; + + // We've already proven that the "proof" is valid so we can extract its key + const proof = Util.tryParse(ancestorProof); + const oldKey = proof && proof[0]; + + let oldBlock; + nThen((w) => { + SSOUtils.readBlock(Env, oldKey, w((err, data) => { + if (err || !data) { + w.abort(); + return void cb('INVALID_OLD_BLOCK'); + } + if (!data.id) { + w.abort(); + return void cb('INVALID_SSO_BLOCK_CONTENT'); + } + oldBlock = data; + })); + }).nThen((w) => { + SSOUtils.writeBlock(Env, publicKey, oldBlock.provider, oldBlock.id, w((err) => { + if (err) { + w.abort(); + return void cb('SSO_UPDATE_BLOCK_WRITE'); + } + })); + }).nThen(() => { + cb(); }); - }; diff --git a/lib/challenge-commands/totp.js b/lib/challenge-commands/totp.js index 21a070895..b0761ca44 100644 --- a/lib/challenge-commands/totp.js +++ b/lib/challenge-commands/totp.js @@ -8,6 +8,7 @@ const MFA = require("../storage/mfa"); const Sessions = require("../storage/sessions"); const BlockStore = require("../storage/block"); const Block = require("../commands/block"); +const SSOUtils = require("../sso-utils"); const Commands = module.exports; @@ -62,16 +63,35 @@ var decode32 = S => { var EXPIRATION = 7 * 24 * 3600 * 1000; // Sessions are valid 7 days // 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(); + + // 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) { 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), @@ -82,7 +102,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, @@ -203,7 +228,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 @@ -253,7 +278,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); }); }; @@ -301,8 +326,8 @@ 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 @@ -432,7 +457,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) => { @@ -444,7 +470,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'); @@ -452,7 +478,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); }); }; diff --git a/lib/commands/admin-rpc.js b/lib/commands/admin-rpc.js index 512fd001b..4e9ed319e 100644 --- a/lib/commands/admin-rpc.js +++ b/lib/commands/admin-rpc.js @@ -11,6 +11,7 @@ const Channel = require("./channel"); const BlockStore = require("../storage/block"); const MFA = require("../storage/mfa"); const ArchiveAccount = require('../archive-account'); +const SSOUtils = require("../sso-utils"); /* jshint ignore:start */ const { Worker } = require('node:worker_threads'); /* jshint ignore:end */ @@ -722,6 +723,7 @@ var archiveBlock = function (Env, Server, cb, data) { }); cb(err); }); + SSOUtils.deleteAccount(Env, key, () => {}); }; var restoreArchivedBlock = function (Env, Server, cb, data) { @@ -736,6 +738,10 @@ var restoreArchivedBlock = function (Env, Server, cb, data) { key: key, reason: reason || '', }); + + // Also restore SSO data + SSOUtils.restoreAccount(Env, key, () => {}); + cb(err); }); }; diff --git a/lib/commands/block.js b/lib/commands/block.js index ca4a77e1e..2fffb998a 100644 --- a/lib/commands/block.js +++ b/lib/commands/block.js @@ -5,6 +5,7 @@ const Nacl = require("tweetnacl/nacl-fast"); const nThen = require("nthen"); const Util = require("../common-util"); const BlockStore = require("../storage/block"); +const SSOUtils = require("../sso-utils"); var isString = s => typeof(s) === 'string'; Block.isValidBlockId = id => { @@ -182,5 +183,17 @@ 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. + if (reason !== 'PASSWORD_CHANGE') { + SSOUtils.deleteAccount(Env, publicKey, () => {}); + } else { + SSOUtils.deleteBlock(Env, publicKey, () => {}); + } }; diff --git a/lib/http-commands.js b/lib/http-commands.js index cf4cf3ad9..e573d5b83 100644 --- a/lib/http-commands.js +++ b/lib/http-commands.js @@ -62,7 +62,7 @@ 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"); @@ -70,13 +70,14 @@ COMMANDS.TOTP_SETUP = TOTP.TOTP_SETUP; COMMANDS.TOTP_VALIDATE = TOTP.TOTP_VALIDATE; 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; const SSO = require("./challenge-commands/sso.js"); COMMANDS.SSO_AUTH = SSO.SSO_AUTH; COMMANDS.SSO_AUTH_CB = SSO.SSO_AUTH_CB; -COMMANDS.SSO_WRITE_BLOCK = SSO.SSO_WRITE_BLOCK; +COMMANDS.SSO_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; var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-'); diff --git a/lib/http-worker.js b/lib/http-worker.js index 90b0babfe..de3b93437 100644 --- a/lib/http-worker.js +++ b/lib/http-worker.js @@ -204,7 +204,6 @@ const wsProxy = createProxyMiddleware({ app.use('/cryptpad_websocket', wsProxy); app.use('/ssoauth', (req, res, next) => { - console.log(req.body.SAMLResponse); if (req && req.body && req.body.SAMLResponse) { req.method = 'GET'; @@ -407,7 +406,8 @@ app.use('/block/', function (req, res, next) { var no = function () { w.abort(); res.status(401).json({ - method: (mfa_params && mfa_params.method) || (sso_params && 'SSO'), + sso: Boolean(sso_params), + method: mfa_params && mfa_params.method, code: 401 }); }; @@ -427,7 +427,8 @@ 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, }); } @@ -439,7 +440,6 @@ app.use('/block/', function (req, res, next) { if (content.mfa && content.mfa.exp && (+new Date()) > content.mfa.exp) { Log.error("OTP_SESSION_EXPIRED", content.mfa); - // XXX Only delete the mfa part Sessions.delete(Env, name, token, function (err) { if (err) { Log.error('SESSION_DELETE_EXPIRED_ERROR', err); diff --git a/lib/sso-utils.js b/lib/sso-utils.js index b1fa2f8f6..dd9f8136f 100644 --- a/lib/sso-utils.js +++ b/lib/sso-utils.js @@ -4,6 +4,7 @@ const Nacl = require("tweetnacl/nacl-fast"); const JWT = require("jsonwebtoken"); const Util = require("./common-util"); const config = require("./load-config"); +const nThen = require("nthen"); const SSOUtils = module.exports; @@ -17,7 +18,6 @@ SSOUtils.getOptions = () => { }; }; -// XXX const SAML = require('node-saml'); // https://www.npmjs.com/package/node-saml const TYPES = SSOUtils.TYPES = { oidc: require('./plugins/sso/oidc'), saml: require('./plugins/sso/saml') @@ -82,8 +82,11 @@ SSOUtils.readUser = (Env, provider, id, cb) => { cb(void 0, Util.tryParse(user)); }); }; +SSOUtils.deleteUser = (Env, provider, id, cb) => { + SSO.user.archive(Env, provider, id, cb); +}; SSOUtils.updateUser = (Env, provider, id, data, cb) => { - SSO.user.delete(Env, provider, id, () => { + SSO.user.archive(Env, provider, id, () => { SSO.user.write(Env, provider, id, JSON.stringify(data), (err) => { if (err) { return void cb(err); } cb(); @@ -91,9 +94,10 @@ SSOUtils.updateUser = (Env, provider, id, data, cb) => { }); }; -SSOUtils.writeBlock = (Env, id, ssoID, cb) => { +SSOUtils.writeBlock = (Env, id, provider, ssoID, cb) => { SSO.block.write(Env, id, JSON.stringify({ - id: ssoID + id: ssoID, + provider: provider }), (err) => { if (err) { return void cb(err); } cb(); @@ -101,10 +105,58 @@ SSOUtils.writeBlock = (Env, id, ssoID, cb) => { }; SSOUtils.readBlock = (Env, id, cb) => { SSO.block.read(Env, id, (err, blockData) => { - if (err) { return void cb(err); } + if (err && err !== 'ENOENT' && err.code !== 'ENOENT') { + Env.Log.error("SSO_READ_BLOCK", { + error: Util.serializeError(err), + publicKey: id + }); + } + if (err) { return void cb(err.code || err); } cb(void 0, Util.tryParse(blockData)); }); }; +SSOUtils.deleteBlock = (Env, id, cb) => { + SSO.block.archive(Env, id, (err) => { + if (err) { return void cb(err); } + cb(); + }); +}; + +// Archive SSO account data +SSOUtils.deleteAccount = (Env, publicKey, cb) => { + SSOUtils.readBlock(Env, publicKey, (err, data) => { + if (err && err !== 'ENOENT') { return void cb(err); } + if (!data) { return void cb(); } + let provider = data.provider; + let userId = data.id; + nThen((w) => { + SSOUtils.deleteUser(Env, provider, userId, w((err) => { + Env.Log.error("SSO_DELETE_USER", { + error: Util.serializeError(err), + provider: provider, + id: userId + }); + })); + SSOUtils.deleteBlock(Env, publicKey, w()); + }).nThen(() => { + cb(); + }); + }); +}; +SSOUtils.restoreAccount = (Env, publicKey, cb) => { + SSO.block.restore(Env, publicKey, (err) => { + if (err && err.code === 'ENOENT') { return void cb(); } + if (err) { return void cb(err); } + SSOUtils.readBlock(Env, publicKey, (err, data) => { + let provider = data.provider; + let userId = data.id; + SSO.user.restore(Env, provider, userId, (err) => { + cb(err); + }); + }); + }); +}; + // Store the SSO data (tokens, etc.) in a JWT while waiting for the user's CryptPad password SSOUtils.createJWT = (Env, ssoId, provider, data, cb) => { @@ -149,7 +201,6 @@ SSOUtils.checkJWT = (Env, token, cb) => { SSOUtils.makeSession = (Env, publicKey, provider, ssoData, cb) => { const sessionId = Sessions.randomId(); - // XXX If we already have an OTP session, recover it Sessions.write(Env, publicKey, sessionId, JSON.stringify({ sso: { exp: +new Date() + SESSION_EXPIRATIION, @@ -171,3 +222,4 @@ SSOUtils.makeSession = (Env, publicKey, provider, ssoData, cb) => { }); }; + diff --git a/lib/storage/basic.js b/lib/storage/basic.js index c164cceae..298c72865 100644 --- a/lib/storage/basic.js +++ b/lib/storage/basic.js @@ -21,6 +21,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) => { @@ -66,4 +67,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); + }); +}; diff --git a/lib/storage/sessions.js b/lib/storage/sessions.js index b5f494447..907306f05 100644 --- a/lib/storage/sessions.js +++ b/lib/storage/sessions.js @@ -42,6 +42,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); diff --git a/lib/storage/sso.js b/lib/storage/sso.js index 3e41bb5d8..e2ee8360f 100644 --- a/lib/storage/sso.js +++ b/lib/storage/sso.js @@ -7,7 +7,7 @@ const SSO = module.exports; 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 databse of blocks that are sso-protected (block id ==> SSO id...) +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. @@ -32,6 +32,13 @@ var userPathFromId = function (Env, id, provider) { 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) { @@ -62,6 +69,16 @@ 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 = {}; @@ -77,3 +94,13 @@ 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); +}; diff --git a/www/common/common-constants.js b/www/common/common-constants.js index 888edeafd..12c4ec818 100644 --- a/www/common/common-constants.js +++ b/www/common/common-constants.js @@ -6,6 +6,7 @@ define(['/customize/application_config.js'], function (AppConfig) { blockHashKey: 'Block_hash', fileHashKey: 'FS_hash', sessionJWT: 'Session_JWT', + ssoSeed: 'SSO_seed', // Store displayNameKey: 'cryptpad.username', diff --git a/www/common/common-login.js b/www/common/common-login.js index d2776dcb7..fede6b609 100644 --- a/www/common/common-login.js +++ b/www/common/common-login.js @@ -299,7 +299,8 @@ define([ // results... var res = { register: isRegister, - uname: uname + uname: uname, + auth_token: {} }; var RT, blockKeys, blockUrl; @@ -323,11 +324,12 @@ define([ // determine where a block for your set of keys would be stored blockUrl = Block.getBlockUrl(res.opt.blockKeys); - var TOTP_prompt = function (err, cb) { + 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: @@ -352,11 +354,13 @@ define([ }; 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"); } @@ -395,19 +399,24 @@ define([ }); })); }).nThen(function (w) { - if (missingAuth !== 'SSO') { return; } // XXX multiple auth + 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) { - // XXX - return; + console.error(err); + w.abort(); + waitFor.abort(); + return void cb(err); } res.auth_token = response; })); }).nThen(function (w) { - if (missingAuth !== 'TOTP') { return; } // XXX multiple auth + if (missingAuth !== 'TOTP') { return; } // if you're here then you need to request a JWT var done = w(); var tries = 3; @@ -418,7 +427,9 @@ define([ return void cb('TOTP_ATTEMPTS_EXHAUSTED'); } tries--; - TOTP_prompt(tries !== 2, function (err, response) { + // 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); @@ -477,6 +488,7 @@ define([ }).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(); @@ -497,11 +509,14 @@ define([ 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 + content: toPublish, }, waitFor(function (e, res) { if (e === 'SSO_NO_SESSION') { return; } // account created, need re-login if (e) { diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index 3ab33e5b8..f0a4d3801 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -1994,6 +1994,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); @@ -2042,21 +2047,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; diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index 954ca4ba7..f2a6ad358 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -881,6 +881,7 @@ define([ })); }).nThen(function (waitFor) { // Delete Drive + store.ownDeletion = true; Store.removeOwnedChannel(clientId, { channel: store.driveChannel, force: true @@ -3055,6 +3056,7 @@ define([ }) .on('error', function (info) { if (info.error && info.error === 'EDELETED') { + if (store.ownDeletion) { return; } broadcast([], "LOGOUT", { reason: info.message }); diff --git a/www/common/outer/local-store.js b/www/common/outer/local-store.js index 845a6393a..d14532d7c 100644 --- a/www/common/outer/local-store.js +++ b/www/common/outer/local-store.js @@ -47,7 +47,7 @@ define([ return hash; }; - var getUserHash = LocalStore.getUserHash = function () { + LocalStore.getUserHash = function () { var hash = localStorage[Constants.userHashKey]; if (['undefined', 'undefined/'].indexOf(hash) !== -1) { @@ -84,6 +84,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]; }; @@ -130,6 +137,7 @@ define([ Constants.userHashKey, Constants.blockHashKey, Constants.sessionJWT, + Constants.ssoSeed, 'loginToken', 'plan', ].forEach(function (k) { diff --git a/www/common/outer/login-block.js b/www/common/outer/login-block.js index dcabdaaad..7872d9b88 100644 --- a/www/common/outer/login-block.js +++ b/www/common/outer/login-block.js @@ -173,7 +173,7 @@ define([ }, cb); }; Block.writeLoginBlock = function (data, cb) { - const { content, blockKeys, oldBlockKeys, auth, pw } = data; + const { content, blockKeys, oldBlockKeys, auth, pw, session } = data; var command = 'WRITE_BLOCK'; if (auth && auth.type) { command = `${auth.type.toUpperCase()}_` + command; } @@ -185,7 +185,8 @@ define([ ServerCommand(blockKeys.sign, { command: command, - content: block + content: block, + session: session // sso session }, cb); }; Block.removeLoginBlock = function (data, cb) { @@ -201,5 +202,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; }); diff --git a/www/settings/inner.js b/www/settings/inner.js index 9b304a2dd..7500e3c0a 100644 --- a/www/settings/inner.js +++ b/www/settings/inner.js @@ -577,10 +577,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; })); @@ -723,8 +730,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; })); @@ -1053,6 +1067,7 @@ define([ var Base32, QRCode, Nacl; var blockKeys; var recoverySecret; + var ssoSeed; nThen(function (waitFor) { require([ '/auth/base32.js', @@ -1063,12 +1078,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, @@ -1160,7 +1182,6 @@ define([ lock = true; var data = { - command: 'TOTP_SETUP', secret: secret, contact: "secret:" + recoverySecret, // TODO other recovery options code: code, diff --git a/www/settings/main.js b/www/settings/main.js index 371c4f899..26705d5a3 100644 --- a/www/settings/main.js +++ b/www/settings/main.js @@ -81,7 +81,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); @@ -111,11 +114,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); }); diff --git a/www/ssoauth/main.js b/www/ssoauth/main.js index 758e823ce..db51ee60b 100644 --- a/www/ssoauth/main.js +++ b/www/ssoauth/main.js @@ -58,6 +58,7 @@ define([ $button.prop('disabled', ''); return void UI.warn(msg); } + LocalStore.setSSOSeed(seed.toLowerCase()); window.location.href = '/drive/'; }); };