@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

@ -88,6 +88,34 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
}
}
/**
* Turning 2-step verification off, wiping it, replacing its secret or showing
* the secret all used to need only the session. Whoever held a session (a
* stolen cookie, an unlocked machine) could quietly drop the second factor or
* copy it. While it is enabled, these now need a current code or backup code,
* as login does. A used backup code is spent.
*/
private function requireCurrentCode(MainAccount $oAccount) : void
{
$aData = $this->getTwoFactorInfo($oAccount);
if (empty($aData['IsSet']) || empty($aData['Enable']) || empty($aData['Secret'])) {
return;
}
$sCode = \trim($this->jsonParam('Code', ''));
if (\strlen($sCode)) {
if ($this->TwoFactorAuthProvider($oAccount)->VerifyCode($aData['Secret'], $sCode)) {
return;
}
$aBackupCodes = \explode(' ', \trim(\preg_replace('/[^\d]+/', ' ', $aData['BackupCodes'] ?? '')));
if (6 < \strlen($sCode) && \in_array($sCode, $aBackupCodes, true)) {
$this->removeBackupCodeFromTwoFactorInfo($oAccount, $sCode);
return;
}
}
$this->Logger()->Write("TFA: change refused for {$oAccount->Email()}, no valid current code");
throw new ClientException(\RainLoop\Notifications::AuthError);
}
public function DoGetTwoFactorInfo() : array
{
$oAccount = $this->getMainAccountFromToken();
@ -107,11 +135,15 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
return $this->jsonResponse(__FUNCTION__, false);
}
// Overwrites the stored secret with Enable false, i.e. also switches it off
$this->requireCurrentCode($oAccount);
$sEmail = $oAccount->Email();
$sSecret = $this->TwoFactorAuthProvider($oAccount)->CreateSecret();
$aCodes = \array_map(function(){return \rand(100000000, 900000000);}, \array_fill(0, 8, null));
// Backup codes are a second factor; rand() is not a CSPRNG
$aCodes = \array_map(fn() => \random_int(100000000, 899999999), \array_fill(0, 8, null));
$this->StorageProvider()->Put($oAccount,
\RainLoop\Providers\Storage\Enumerations\StorageType::CONFIG,
@ -148,6 +180,8 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
return $this->jsonResponse(__FUNCTION__, false);
}
$this->requireCurrentCode($oAccount);
$aResult = $this->getTwoFactorInfo($oAccount);
unset($aResult['BackupCodes']);
@ -164,6 +198,10 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
return $this->jsonResponse(__FUNCTION__, false);
}
if ('1' !== \trim($this->jsonParam('Enable', '0'))) {
$this->requireCurrentCode($oAccount);
}
$oActions = $this->Manager()->Actions();
if ($oActions->HasActionParam('EnableTwoFactor')) {
$sValue = $oActions->GetActionParam('EnableTwoFactor', '');
@ -215,6 +253,8 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
return $this->jsonResponse(__FUNCTION__, false);
}
$this->requireCurrentCode($oAccount);
$this->StorageProvider()->Clear($oAccount,
\RainLoop\Providers\Storage\Enumerations\StorageType::CONFIG,
'two_factor'