mirror of https://github.com/xwiki-labs/cryptpad
TOTP setup and revocation in settings
This commit is contained in:
parent
bf548c1022
commit
d789627920
|
@ -48,6 +48,11 @@ var decode32 = S => {
|
|||
return decoded;
|
||||
};
|
||||
|
||||
|
||||
// XXX Decide expire time
|
||||
// Allow user settings?
|
||||
var EXPIRATION = 7 * 24 * 3600 * 1000; // Sessions are valid 7 days
|
||||
|
||||
var createJWT = function (Env, sessionId, publicKey, cb) {
|
||||
JWT.sign({
|
||||
// this is a custom JWT field (not a standard) - we include a reference to the session
|
||||
|
@ -55,6 +60,7 @@ var createJWT = function (Env, sessionId, publicKey, cb) {
|
|||
ref: sessionId,
|
||||
// we specify in the token for what resource the token should be valid (their block's public key)
|
||||
sub: Util.escapeKeyCharacters(publicKey),
|
||||
exp: (+new Date()) + EXPIRATION
|
||||
}, Env.bearerSecret, {
|
||||
// token integrity is ensured with HMAC SHA512 with the server's bearerSecret
|
||||
// clients can inspect token parameters, but cannot modify them
|
||||
|
@ -336,3 +342,112 @@ So, we should:
|
|||
});
|
||||
};
|
||||
|
||||
// This command is somewhat simpler than TOTP_SETUP
|
||||
// Revoke a client TOTP secret which will allow them to disable TOTP for a login block IFF:
|
||||
// 1. That login block exists
|
||||
// 2. That login block is protected by TOTP 2FA
|
||||
// 3. They can produce a valid OTP for that block's TOTP secret
|
||||
// 4. They can sign for the block's public key
|
||||
const revoke = Commands.TOTP_REVOKE = function (Env, body, cb) {
|
||||
var { publicKey, code } = body;
|
||||
|
||||
// they must provide a valid OTP code
|
||||
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
|
||||
|
||||
// they must provide a valid block public key
|
||||
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
|
||||
|
||||
var secret;
|
||||
nThen(function (w) {
|
||||
// check that there is an MFA configuration for the given account
|
||||
MFA.read(Env, publicKey, w(function (err, content) {
|
||||
if (err) {
|
||||
w.abort();
|
||||
Env.Log.error('TOTP_VALIDATE_MFA_READ', {
|
||||
error: err,
|
||||
publicKey: publicKey,
|
||||
});
|
||||
return void cb('NO_MFA_CONFIGURED');
|
||||
}
|
||||
|
||||
var parsed = Util.tryParse(content);
|
||||
|
||||
if (!parsed) {
|
||||
w.abort();
|
||||
return void cb("INVALID_CONFIGURATION");
|
||||
}
|
||||
|
||||
secret = parsed.secret;
|
||||
}));
|
||||
}).nThen(function () {
|
||||
let decoded = decode32(secret);
|
||||
if (!decoded) {
|
||||
Env.Log.error("TOTP_VALIDATE_INVALID_SECRET", {
|
||||
publicKey, // log the public key so the admin can investigate further
|
||||
// don't log the problematic secret directly as
|
||||
// logs are likely to be pasted in random places
|
||||
});
|
||||
return void cb("E_INVALID_SECRET");
|
||||
}
|
||||
|
||||
// validate the code
|
||||
var validated = OTP.totp.verify(code, decoded, {
|
||||
window: 1,
|
||||
});
|
||||
|
||||
if (!validated) {
|
||||
// I won't worry about logging these OTPs as they shouldn't leak any useful information
|
||||
Env.Log.error("TOTP_VALIDATE_BAD_OTP", {
|
||||
code,
|
||||
});
|
||||
return void cb("INVALID_OTP");
|
||||
}
|
||||
|
||||
// call back to indicate that their request was well-formed and valid
|
||||
cb();
|
||||
});
|
||||
};
|
||||
|
||||
revoke.complete = function (Env, body, cb) {
|
||||
/*
|
||||
if they are here then they:
|
||||
|
||||
1. have a valid block configured with TOTP-based 2FA
|
||||
2. were able to provide a valid TOTP for that block's secret
|
||||
3. were able to sign their messages for the block's public key
|
||||
|
||||
So, we should:
|
||||
|
||||
1. Revoke the TOTP authentication for their block
|
||||
2. Remove all existing sessions
|
||||
|
||||
*/
|
||||
var { publicKey } = body;
|
||||
|
||||
nThen(function (w) {
|
||||
MFA.delete(Env, publicKey, w(function (err) {
|
||||
if (!err) { return; }
|
||||
w.abort();
|
||||
Env.Log.error('TOTP_REVOKE_MFA_DELETE', {
|
||||
error: err,
|
||||
publicKey: publicKey,
|
||||
});
|
||||
cb('MFA_ERROR');
|
||||
}));
|
||||
}).nThen(function () {
|
||||
Sessions.deleteUser(Env, publicKey, function (err) {
|
||||
if (!err) { return; }
|
||||
// If we can't delete the sessions, don't send an erorr, just log to the server.
|
||||
// The MFA will still be correctly disabled as long as the first step is done.
|
||||
Env.Log.error('TOTP_REVOKE_SESSIONS__DELETE', {
|
||||
error: err,
|
||||
publicKey: publicKey,
|
||||
});
|
||||
});
|
||||
}).nThen(function () {
|
||||
cb(void 0, {
|
||||
success: true
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -63,6 +63,7 @@ var COMMANDS = {};
|
|||
const TOTP = require("./challenge-commands/totp.js");
|
||||
COMMANDS.TOTP_SETUP = TOTP.TOTP_SETUP;
|
||||
COMMANDS.TOTP_VALIDATE = TOTP.TOTP_VALIDATE;
|
||||
COMMANDS.TOTP_REVOKE = TOTP.TOTP_REVOKE;
|
||||
|
||||
var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-');
|
||||
|
||||
|
|
|
@ -380,6 +380,13 @@ app.use('/block/', function (req, res, next) {
|
|||
// reject if it's too old
|
||||
if (payload.exp && ((+new Date()) > payload.exp)) {
|
||||
Log.error("JWT_EXPIRED", payload);
|
||||
Sessions.delete(Env, name, payload.ref, function (err) {
|
||||
if (err) {
|
||||
Log.error('JWT_SESSION_DELETE_EXPIRED_ERROR', err);
|
||||
return;
|
||||
}
|
||||
Log.info('JWT_SESSION_DELETE_EXPIRED', err);
|
||||
});
|
||||
return void no();
|
||||
}
|
||||
|
||||
|
|
|
@ -36,6 +36,10 @@ Basic.read = function (Env, path, cb) {
|
|||
cb(void 0, content);
|
||||
});
|
||||
};
|
||||
Basic.readDir = function (Env, path, cb) {
|
||||
if (!path) { return void pathError(cb); }
|
||||
Fs.readdir(path, cb);
|
||||
};
|
||||
|
||||
Basic.write = function (Env, path, data, cb) {
|
||||
if (!path) { return void pathError(cb); }
|
||||
|
@ -57,4 +61,9 @@ Basic.delete = function (Env, path, cb) {
|
|||
if (!path) { return void pathError(cb); }
|
||||
Fs.rm(path, cb);
|
||||
};
|
||||
Basic.deleteDir = function (Env, path, cb) {
|
||||
if (!path) { return void pathError(cb); }
|
||||
Fs.rm(path, { recursive: true, force: true }, cb);
|
||||
};
|
||||
|
||||
|
||||
|
|
|
@ -42,6 +42,20 @@ Sessions.delete = function (Env, id, ref, cb) {
|
|||
Basic.delete(Env, path, cb);
|
||||
};
|
||||
|
||||
Sessions.deleteUser = function (Env, id, cb) {
|
||||
if (!id || typeof(id) !== 'string') { return; }
|
||||
id = Util.escapeKeyCharacters(id);
|
||||
var dirPath = Path.join(Env.paths.base, "sessions", id.slice(0, 2), id);
|
||||
|
||||
Basic.readDir(Env, dirPath, (err, files) => {
|
||||
var checkContent = !files || (Array.isArray(files) && files.every((file) => {
|
||||
return file && file.length === 32;
|
||||
}));
|
||||
if (!checkContent) { return void cb('INVALID_SESSIONS_DIR'); }
|
||||
Basic.deleteDir(Env, dirPath, cb);
|
||||
});
|
||||
};
|
||||
|
||||
// XXX All of a user's sessions should be removed When a user deletes their account
|
||||
// The fact that each user is given their own publicKey-scoped directory makes them easy
|
||||
// to remove all at once. Nodejs provides an easy way to `rm -rf` since 14.14.0:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* jshint esversion: 7 */
|
||||
define([], function () {
|
||||
// Based on https://gist.github.com/bellbind/871b145110c458e83077a718aef9fa0e
|
||||
|
||||
|
@ -42,7 +43,7 @@ define([], function () {
|
|||
}
|
||||
function b32d(bs) {
|
||||
const len = bs.length;
|
||||
if (len === 0) return new Uint8Array([]);
|
||||
if (len === 0) { return new Uint8Array([]); }
|
||||
//console.assert(len % 8 === 0, len);
|
||||
const pad = len - bs.indexOf("="), rem = b32pad.indexOf(pad);
|
||||
//console.assert(rem >= 0, pad);
|
||||
|
|
|
@ -175,7 +175,7 @@ Note: This must currently be reversed manually (by deleting the mfa config file)
|
|||
$deriveKeys.click(function () {
|
||||
if (BUSY) { return; }
|
||||
|
||||
var name = $username.val().trim()
|
||||
var name = $username.val().trim();
|
||||
var password = $password.val();
|
||||
|
||||
if (!name) { return void window.alert("Invalid name"); }
|
||||
|
@ -212,7 +212,7 @@ Note: This must currently be reversed manually (by deleting the mfa config file)
|
|||
|
||||
// TOTP app configuration
|
||||
|
||||
var $generateSecret = $('#generate-secret')
|
||||
var $generateSecret = $('#generate-secret');
|
||||
var $b32Secret = $('#base32-secret');
|
||||
|
||||
var randomSecret = () => {
|
||||
|
|
|
@ -2513,6 +2513,10 @@ define([
|
|||
// when it was expected. Log them out and redirect them to
|
||||
// the login page, where they will be able to authenticate
|
||||
// and request a new JWT
|
||||
|
||||
// XXX We may only require them to provid a new TOTP code here
|
||||
// instead of redirecting them to the login page
|
||||
|
||||
waitFor.abort();
|
||||
return void LocalStore.logout(function () {
|
||||
requestLogin();
|
||||
|
|
|
@ -15,6 +15,7 @@ define([
|
|||
'/common/make-backup.js',
|
||||
'/common/common-feedback.js',
|
||||
'/common/common-constants.js',
|
||||
'/customize.dist/login.js',
|
||||
|
||||
'/common/jscolor.js',
|
||||
'/bower_components/file-saver/FileSaver.min.js',
|
||||
|
@ -37,7 +38,8 @@ define([
|
|||
ApiConfig,
|
||||
Backup,
|
||||
Feedback,
|
||||
Constants
|
||||
Constants,
|
||||
Login
|
||||
) {
|
||||
var saveAs = window.saveAs;
|
||||
var APP = window.APP = {};
|
||||
|
@ -47,6 +49,17 @@ define([
|
|||
var privateData;
|
||||
var sframeChan;
|
||||
|
||||
Messages.settings_totpTitle = "TOTP"; // XXX
|
||||
Messages.settings_cat_access = "Security"; // XXX
|
||||
Messages.settings_totp_enable = "Enable TOTP"; // XXX
|
||||
Messages.settings_totp_disable = "Disable TOTP"; // XXX
|
||||
Messages.settings_totp_generate = "Generate secret"; // XXX
|
||||
Messages.settings_totp_code = "OTP code"; // XXX
|
||||
Messages.settings_totp_code_invalid = "Invalid OTP code"; // XXX
|
||||
|
||||
Messages.settings_totp_tuto = "Scan this QR code with a authenticator application. Obtain a valid authentication code and confirm before it expires."; // XXX
|
||||
Messages.settings_totp_confirm = "Enable TOTP with this secret"; // XXX
|
||||
|
||||
var categories = {
|
||||
'account': [ // Msg.settings_cat_account
|
||||
'cp-settings-own-drive',
|
||||
|
@ -57,6 +70,10 @@ define([
|
|||
'cp-settings-change-password',
|
||||
'cp-settings-delete'
|
||||
],
|
||||
'access': [ // Msg.settings_cat_access // XXX
|
||||
// XXX add password change and account deletion here?
|
||||
'cp-settings-totp'
|
||||
],
|
||||
'security': [ // Msg.settings_cat_security
|
||||
'cp-settings-logout-everywhere',
|
||||
'cp-settings-autostore',
|
||||
|
@ -777,6 +794,238 @@ define([
|
|||
cb($inputBlock);
|
||||
}, true);
|
||||
|
||||
|
||||
// Account access
|
||||
|
||||
var drawTotp = function (content, enabled) {
|
||||
var $content = $(content).empty();
|
||||
if (enabled) {
|
||||
(function () {
|
||||
var disable = h('button.btn.btn-danger', Messages.settings_totp_disable);
|
||||
var OTPEntry, pwInput;
|
||||
$content.append(h('div', [
|
||||
h('p', pwInput = h('input', {
|
||||
type: 'password',
|
||||
placeholder: Messages.login_password,
|
||||
})),
|
||||
OTPEntry = h('input', {
|
||||
placeholder: Messages.settings_totp_code
|
||||
}),
|
||||
disable
|
||||
]));
|
||||
var $b = $(disable);
|
||||
var $OTPEntry = $(OTPEntry);
|
||||
UI.confirmButton(disable, {
|
||||
classes: 'btn-danger',
|
||||
multiple: true
|
||||
}, function () {
|
||||
$b.attr('disabled', 'disabled');
|
||||
var name = privateData.accountName;
|
||||
var password = $(pwInput).val();
|
||||
|
||||
// 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 result = Login.allocateBytes(bytes);
|
||||
sframeChan.query("Q_SETTINGS_CHECK_BLOCK", {
|
||||
blockHash: result.blockHash,
|
||||
}, function (err, obj) {
|
||||
if (!obj || !obj.correct) {
|
||||
UI.warn(Messages.login_noSuchUser);
|
||||
$b.removeAttr('disabled');
|
||||
return;
|
||||
}
|
||||
var blockKeys = result.blockKeys;
|
||||
var code = $OTPEntry.val();
|
||||
sframeChan.query("Q_SETTINGS_TOTP_REVOKE", {
|
||||
key: blockKeys.sign,
|
||||
data: {
|
||||
command: 'TOTP_REVOKE',
|
||||
code: code,
|
||||
}
|
||||
}, function (err, obj) {
|
||||
$OTPEntry.val("");
|
||||
if (err || !obj || !obj.success) {
|
||||
$b.removeAttr('disabled');
|
||||
return void UI.warn(Messages.settings_totp_code_invalid);
|
||||
}
|
||||
drawTotp(content, false);
|
||||
}, {raw: true});
|
||||
});
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
var button = h('button.btn.btn-primary', Messages.settings_totp_enable);
|
||||
$(button).click(function () {
|
||||
$content.empty();
|
||||
var Base32, Block, QRCode, Nacl;
|
||||
var blockKeys;
|
||||
nThen(function (waitFor) {
|
||||
require([
|
||||
'/auth/base32.js',
|
||||
'/common/outer/login-block.js',
|
||||
'/lib/qrcode.min.js',
|
||||
'/bower_components/tweetnacl/nacl-fast.min.js',
|
||||
], waitFor(function (_Base32, _Login, _Block) {
|
||||
Base32 = _Base32;
|
||||
Block = _Block;
|
||||
QRCode = window.QRCode;
|
||||
Nacl = window.nacl;
|
||||
}));
|
||||
}).nThen(function (waitFor) {
|
||||
var pwInput;
|
||||
var button = h('button.btn.btn-secondary', Messages.ui_confirm);
|
||||
$content.append(h('div', [
|
||||
h('p', pwInput = h('input', {
|
||||
type: 'password',
|
||||
placeholder: Messages.login_password,
|
||||
})),
|
||||
button
|
||||
]));
|
||||
var next = waitFor();
|
||||
var BUSY = false;
|
||||
|
||||
var spinner = UI.makeSpinner($content);
|
||||
var $b = $(button).click(function () {
|
||||
if (BUSY) { return; }
|
||||
|
||||
var name = privateData.accountName;
|
||||
var password = $(pwInput).val();
|
||||
|
||||
if (!password) { return void UI.warn(Messages.login_noSuchUser); }
|
||||
|
||||
spinner.spin();
|
||||
$b.attr('disabled', 'disabled');
|
||||
BUSY = true;
|
||||
|
||||
// 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 result = Login.allocateBytes(bytes);
|
||||
sframeChan.query("Q_SETTINGS_CHECK_BLOCK", {
|
||||
blockHash: result.blockHash,
|
||||
}, function (err, obj) {
|
||||
BUSY = false;
|
||||
if (!obj || !obj.correct) {
|
||||
spinner.hide();
|
||||
UI.warn(Messages.login_noSuchUser);
|
||||
$b.removeAttr('disabled');
|
||||
return;
|
||||
}
|
||||
spinner.done();
|
||||
blockKeys = result.blockKeys;
|
||||
next();
|
||||
});
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
}).nThen(function () {
|
||||
var randomSecret = function () {
|
||||
var U8 = Nacl.randomBytes(20);
|
||||
return Base32.encode(U8);
|
||||
};
|
||||
$content.empty();
|
||||
var generate = h('button.btn.btn-primary', Messages.settings_totp_generate);
|
||||
var secretContainer = h('div');
|
||||
var $container = $(secretContainer);
|
||||
$content.append(secretContainer, h('p', generate));
|
||||
|
||||
var updateQR = Util.throttle(function (uri, target) {
|
||||
new QRCode(target, uri);
|
||||
}, 400);
|
||||
var updateURI = function (secret) {
|
||||
$container.empty();
|
||||
|
||||
var username = privateData.accountName;
|
||||
var hostname = new URL(privateData.origin).hostname;
|
||||
var label = "CryptPad";
|
||||
|
||||
var uri = `otpauth://totp/${label}:${username}@${hostname}?secret=${secret}`;
|
||||
|
||||
var qr = h('div');
|
||||
var uriInput = UI.dialog.selectable(uri);
|
||||
updateQR(uri, qr);
|
||||
|
||||
var OTPEntry = h('input', {
|
||||
placeholder: Messages.settings_totp_code
|
||||
});
|
||||
var $OTPEntry = $(OTPEntry);
|
||||
|
||||
var description = h('p', Messages.settings_totp_tuto);
|
||||
var confirmOTP = h('button.btn.btn-primary', Messages.settings_totp_confirm);
|
||||
var $confirmBtn = $(confirmOTP);
|
||||
var lock = false;
|
||||
UI.confirmButton(confirmOTP, {
|
||||
multiple: true
|
||||
}, function () {
|
||||
var code = $OTPEntry.val();
|
||||
if (code.length !== 6 || /\D/.test(code)) {
|
||||
return void UI.warn(Messages.error); // XXX
|
||||
}
|
||||
$confirmBtn.attr('disabled', 'disabled');
|
||||
lock = true;
|
||||
|
||||
sframeChan.query("Q_SETTINGS_TOTP_SETUP", {
|
||||
key: blockKeys.sign,
|
||||
data: {
|
||||
command: 'TOTP_SETUP',
|
||||
secret: secret,
|
||||
code: code,
|
||||
}
|
||||
}, function (err, obj) {
|
||||
lock = false;
|
||||
$OTPEntry.val("");
|
||||
if (err || !obj || !obj.success) {
|
||||
$confirmBtn.removeAttr('disabled');
|
||||
console.error(err);
|
||||
return void UI.warn(Messages.error);
|
||||
}
|
||||
drawTotp(content, true);
|
||||
}, {raw: true});
|
||||
|
||||
});
|
||||
|
||||
$container.append([
|
||||
uriInput,
|
||||
qr,
|
||||
h('br'),
|
||||
description,
|
||||
OTPEntry,
|
||||
confirmOTP
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
var $g = $(generate).click(function () {
|
||||
$g.remove();
|
||||
var secret = randomSecret();
|
||||
updateURI(secret);
|
||||
});
|
||||
});
|
||||
|
||||
}).appendTo(content);
|
||||
};
|
||||
makeBlock('totp', function (cb) { // Msg.settings_totpTitle
|
||||
if (!common.isLoggedIn()) { return void cb(false); }
|
||||
|
||||
var content = h('div');
|
||||
sframeChan.query('Q_SETTINGS_TOTP_CHECK', {}, function (err, obj) {
|
||||
if (err || !obj || (obj && obj.err === 'NOBLOCK')) { return void cb(false); }
|
||||
var enabled = obj && obj.totp;
|
||||
drawTotp(content, Boolean(enabled));
|
||||
cb(content);
|
||||
});
|
||||
}, true);
|
||||
|
||||
|
||||
|
||||
// Security
|
||||
|
||||
makeBlock('safe-links', function(cb) { // Msg.settings_safeLinksTitle
|
||||
|
|
|
@ -70,6 +70,47 @@ define([
|
|||
sframeChan.on('Q_SETTINGS_IMPORT_LOCAL', function (data, cb) {
|
||||
Cryptpad.mergeAnonDrive(cb);
|
||||
});
|
||||
sframeChan.on('Q_SETTINGS_CHECK_BLOCK', function (data, cb) {
|
||||
cb({correct: data.blockHash === Utils.LocalStore.getBlockHash() });
|
||||
});
|
||||
sframeChan.on('Q_SETTINGS_TOTP_SETUP', function (obj, cb) {
|
||||
require([
|
||||
'/common/outer/http-command.js',
|
||||
], function (ServerCommand) {
|
||||
ServerCommand(obj.key, obj.data, function (err, response) {
|
||||
cb({ success: Boolean(!err && response && response.bearer) });
|
||||
console.log(response);
|
||||
if (response && response.bearer) {
|
||||
Utils.LocalStore.setSessionToken(response.bearer);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
sframeChan.on('Q_SETTINGS_TOTP_REVOKE', function (obj, cb) {
|
||||
require([
|
||||
'/common/outer/http-command.js',
|
||||
], function (ServerCommand) {
|
||||
ServerCommand(obj.key, obj.data, function (err, response) {
|
||||
cb({ success: Boolean(!err && response && response.success) });
|
||||
console.error(response);
|
||||
if (response && response.success) {
|
||||
Utils.LocalStore.setSessionToken('');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
sframeChan.on('Q_SETTINGS_TOTP_CHECK', function (obj, cb) {
|
||||
require([
|
||||
'/common/outer/login-block.js',
|
||||
], function (Block) {
|
||||
var blockHash = Utils.LocalStore.getBlockHash();
|
||||
if (!blockHash) { return void cb({ err: 'NOBLOCK' }); }
|
||||
var parsed = Block.parseBlockHash(blockHash);
|
||||
Utils.Util.getBlock(parsed.href, {}, function (err) {
|
||||
cb({totp: err === 401});
|
||||
});
|
||||
});
|
||||
});
|
||||
sframeChan.on('Q_SETTINGS_DELETE_ACCOUNT', function (data, cb) {
|
||||
Cryptpad.deleteAccount(data, cb);
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue