@ZaphodB fix(security): two-factor-auth could be switched off with only a session

With 2-step verification enabled, these plugin actions needed nothing but a
session (and the CSRF token that comes with it):

- EnableTwoFactor with Enable=0 switched it off,
- ClearTwoFactorInfo deleted it,
- CreateTwoFactorSecret overwrote the secret with Enable false, i.e. also
  switched it off,
- ShowTwoFactorSecret returned the secret and QR code.

A stolen cookie or an unlocked machine was enough to drop or copy the second
factor. While 2FA is enabled, all four now require a current TOTP or backup
code, checked as at login; a backup code used here is spent. Enabling and the
first setup are unchanged. Backup codes come from random_int() instead of rand().

The settings page asks for the code (the existing "Verification Code" label)
before those actions and sends it. A refused change reloads the real state;
before, any error made the page show 2FA as not configured. A cancelled or
refused "off" puts the checkbox back. TwoFactorAuthSettings.min.js, which is
what is served, is now the same file as the source rather than a separate
hand-minified copy.

test/two-factor-reauth.php drives the real gate with storage and provider
stubbed: open while not enabled; refused with no, a wrong or an unknown backup
code while enabled; allowed with a valid TOTP or backup code, which is spent.
This commit is contained in:
djmaze 2026-10-27 22:27:03 +01:00
parent a827f8aded
commit a67c85885e
3 changed files with 164 additions and 12 deletions

View file

@ -22,8 +22,9 @@ const
* @param {?Function} fCallback
* @param {boolean} bEnable
*/
enableTwoFactor(fCallback, bEnable) {
enableTwoFactor(fCallback, bEnable, oParams) {
rl.pluginRemoteRequest(fCallback, 'EnableTwoFactor', {
...oParams,
Enable: bEnable ? 1 : 0
});
}
@ -61,8 +62,12 @@ class TwoFactorAuthSettings
rl.settings.get('RequireTwoFactor') && rl.settings.set('SetupTwoFactor', !!iError);
}, value);
} else {
value || this.viewEnable_(value);
Remote.enableTwoFactor(fn, false);
// Turning it off needs a current code while it is on.
// Cancelled or refused, the checkbox goes back to on.
this.withCode(params => {
value || this.viewEnable_(value);
Remote.enableTwoFactor(iError => iError && this.viewEnable_(true), false, params);
}, () => this.viewEnable_.valueHasMutated());
}
}
},
@ -86,12 +91,28 @@ class TwoFactorAuthSettings
}).forEach(([key, fn]) => this[key] = ko.computed(fn));
this.onResult = this.onResult.bind(this);
this.onChangeResult = this.onChangeResult.bind(this);
this.onShowSecretResult = this.onShowSecretResult.bind(this);
}
/**
* While 2-step verification is on, the server wants a current code (or a
* backup code) to turn it off, clear it, replace it or show its secret.
*/
withCode(fDo, fCancel) {
if (!this.viewEnable_()) {
fDo({});
} else {
const code = (prompt(rl.i18n('PLUGIN_2FA/LABEL_TWO_FACTOR_CODE')) || '').trim();
code ? fDo({ Code: code }) : fCancel?.();
}
}
showSecret() {
this.secreting(true);
rl.pluginRemoteRequest(this.onShowSecretResult, 'ShowTwoFactorSecret');
this.withCode(params => {
this.secreting(true);
rl.pluginRemoteRequest(this.onShowSecretResult, 'ShowTwoFactorSecret', params);
});
}
hideSecret() {
@ -101,8 +122,10 @@ class TwoFactorAuthSettings
}
createTwoFactor() {
this.processing(true);
rl.pluginRemoteRequest(this.onResult, 'CreateTwoFactorSecret');
this.withCode(params => {
this.processing(true);
rl.pluginRemoteRequest(this.onChangeResult, 'CreateTwoFactorSecret', params);
});
}
testTwoFactor() {
@ -115,12 +138,26 @@ class TwoFactorAuthSettings
}
clearTwoFactor() {
this.hideSecret();
this.withCode(params => {
this.hideSecret();
this.twoFactorTested(false);
this.twoFactorTested(false);
this.clearing(true);
rl.pluginRemoteRequest(this.onResult, 'ClearTwoFactorInfo');
this.clearing(true);
rl.pluginRemoteRequest(this.onChangeResult, 'ClearTwoFactorInfo', params);
});
}
// A refused change must not show the account as unconfigured, which is what
// onResult does with an error; reload the real state instead.
onChangeResult(iError, oData) {
if (iError) {
this.processing(false);
this.clearing(false);
this.onBuild();
} else {
this.onResult(iError, oData);
}
}
onShow() {