mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-11-04 03:18:49 +03:00
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.
390 lines
12 KiB
PHP
390 lines
12 KiB
PHP
<?php
|
|
|
|
use \RainLoop\Exceptions\ClientException;
|
|
use \RainLoop\Model\Account;
|
|
use \RainLoop\Model\MainAccount;
|
|
|
|
class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
|
|
{
|
|
const
|
|
NAME = 'Two Factor Authentication',
|
|
VERSION = '2.19.0',
|
|
RELEASE = '2024-03-29',
|
|
REQUIRED = '2.36.0',
|
|
CATEGORY = 'Login',
|
|
DESCRIPTION = 'Provides support for TOTP 2FA';
|
|
|
|
public function Init() : void
|
|
{
|
|
$this->UseLangs(true);
|
|
|
|
$this->addJs('js/TwoFactorAuthLogin.js');
|
|
$this->addJs('js/TwoFactorAuthSettings.js');
|
|
|
|
$this->addHook('login.success', 'DoLogin');
|
|
$this->addHook('filter.app-data', 'FilterAppData');
|
|
|
|
$this->addJsonHook('GetTwoFactorInfo', 'DoGetTwoFactorInfo');
|
|
$this->addJsonHook('CreateTwoFactorSecret', 'DoCreateTwoFactorSecret');
|
|
$this->addJsonHook('ShowTwoFactorSecret', 'DoShowTwoFactorSecret');
|
|
$this->addJsonHook('EnableTwoFactor', 'DoEnableTwoFactor');
|
|
$this->addJsonHook('VerifyTwoFactorCode', 'DoVerifyTwoFactorCode');
|
|
$this->addJsonHook('ClearTwoFactorInfo', 'DoClearTwoFactorInfo');
|
|
|
|
$this->addTemplate('templates/TwoFactorAuthSettings.html');
|
|
$this->addTemplate('templates/PopupsTwoFactorAuthTest.html');
|
|
}
|
|
|
|
public function configMapping() : array
|
|
{
|
|
return [
|
|
\RainLoop\Plugins\Property::NewInstance("force_two_factor_auth")
|
|
// ->SetLabel('PLUGIN_TWO_FACTOR/LABEL_FORCE')
|
|
->SetLabel('Enforce 2-Step Verification')
|
|
->SetType(\RainLoop\Enumerations\PluginPropertyType::BOOL),
|
|
];
|
|
}
|
|
|
|
public function FilterAppData($bAdmin, &$aResult)
|
|
{
|
|
if (!$bAdmin && \is_array($aResult)/* && isset($aResult['Auth']) && !$aResult['Auth']*/) {
|
|
$aResult['RequireTwoFactor'] = (bool) $this->Config()->Get('plugin', 'force_two_factor_auth', false);
|
|
|
|
$aResult['SetupTwoFactor'] = false;
|
|
if ($aResult['RequireTwoFactor'] && !empty($aResult['Auth'])) {
|
|
$aData = $this->getTwoFactorInfo($this->getMainAccountFromToken());
|
|
$aResult['SetupTwoFactor'] = empty($aData['IsSet']) || empty($aData['Enable']) || empty($aData['Secret']);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function DoLogin(MainAccount $oAccount)
|
|
{
|
|
if ($this->TwoFactorAuthProvider($oAccount)) {
|
|
$aData = $this->getTwoFactorInfo($oAccount);
|
|
if (isset($aData['IsSet'], $aData['Enable']) && !empty($aData['Secret']) && $aData['IsSet'] && $aData['Enable']) {
|
|
$sCode = \trim($this->jsonParam('totp_code', ''));
|
|
if (empty($sCode)) {
|
|
$this->Logger()->Write("TFA: Code required for {$oAccount->Email()}");
|
|
throw new ClientException(\RainLoop\Notifications::AuthError);
|
|
}
|
|
|
|
$bUseBackupCode = false;
|
|
if (6 < \strlen($sCode) && !empty($aData['BackupCodes'])) {
|
|
$aBackupCodes = \explode(' ', \trim(\preg_replace('/[^\d]+/', ' ', $aData['BackupCodes'])));
|
|
$bUseBackupCode = \in_array($sCode, $aBackupCodes);
|
|
if ($bUseBackupCode) {
|
|
$this->removeBackupCodeFromTwoFactorInfo($oAccount, $sCode);
|
|
}
|
|
}
|
|
|
|
if (!$bUseBackupCode && !$this->TwoFactorAuthProvider($oAccount)->VerifyCode($aData['Secret'], $sCode)) {
|
|
$this->Manager()->Actions()->LoggerAuthHelper($oAccount);
|
|
$this->Logger()->Write("TFA: Code failed for {$oAccount->Email()}");
|
|
throw new ClientException(\RainLoop\Notifications::AuthError);
|
|
}
|
|
$this->Logger()->Write("TFA: Code verified for {$oAccount->Email()}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
if (!$this->TwoFactorAuthProvider($oAccount)) {
|
|
return $this->jsonResponse(__FUNCTION__, false);
|
|
}
|
|
|
|
return $this->jsonResponse(__FUNCTION__, $this->getTwoFactorInfo($oAccount, true));
|
|
}
|
|
|
|
public function DoCreateTwoFactorSecret() : array
|
|
{
|
|
$oAccount = $this->getMainAccountFromToken();
|
|
|
|
if (!$this->TwoFactorAuthProvider($oAccount)) {
|
|
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();
|
|
|
|
// 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,
|
|
'two_factor',
|
|
\json_encode(array(
|
|
'User' => $sEmail,
|
|
'Enable' => false,
|
|
'Secret' => $sSecret,
|
|
'QRCode' => static::getQRCode($oAccount, $sSecret),
|
|
'BackupCodes' => \implode(' ', $aCodes)
|
|
))
|
|
);
|
|
|
|
return $this->jsonResponse(__FUNCTION__, $this->getTwoFactorInfo($oAccount));
|
|
}
|
|
|
|
private static function getQRCode(MainAccount $oAccount, string $secret) : string
|
|
{
|
|
$email = \rawurlencode($oAccount->Email());
|
|
// $issuer = \rawurlencode(\RainLoop\API::Config()->Get('webmail', 'title', 'SnappyMail'));
|
|
$QR = \SnappyMail\QRCode::getMinimumQRCode(
|
|
// "otpauth://totp/{$issuer}:{$email}?secret={$secret}&issuer={$issuer}",
|
|
"otpauth://totp/{$email}?secret={$secret}",
|
|
\SnappyMail\QRCode::ERROR_CORRECT_LEVEL_M
|
|
);
|
|
return $QR->__toString();
|
|
}
|
|
|
|
public function DoShowTwoFactorSecret() : array
|
|
{
|
|
$oAccount = $this->getMainAccountFromToken();
|
|
|
|
if (!$this->TwoFactorAuthProvider($oAccount)) {
|
|
return $this->jsonResponse(__FUNCTION__, false);
|
|
}
|
|
|
|
$this->requireCurrentCode($oAccount);
|
|
|
|
$aResult = $this->getTwoFactorInfo($oAccount);
|
|
unset($aResult['BackupCodes']);
|
|
|
|
$aResult['QRCode'] = static::getQRCode($oAccount, $aResult['Secret']);
|
|
|
|
return $this->jsonResponse(__FUNCTION__, $aResult);
|
|
}
|
|
|
|
public function DoEnableTwoFactor() : array
|
|
{
|
|
$oAccount = $this->getMainAccountFromToken();
|
|
|
|
if (!$this->TwoFactorAuthProvider($oAccount)) {
|
|
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', '');
|
|
$oActions->SettingsProvider()->Load($oAccount)->SetConf('EnableTwoFactor', !empty($sValue));
|
|
}
|
|
|
|
$sEmail = $oAccount->Email();
|
|
|
|
$bResult = false;
|
|
$mData = $this->getTwoFactorInfo($oAccount);
|
|
if (isset($mData['Secret'], $mData['BackupCodes'])) {
|
|
$bResult = $this->StorageProvider()->Put($oAccount,
|
|
\RainLoop\Providers\Storage\Enumerations\StorageType::CONFIG,
|
|
'two_factor',
|
|
\json_encode(array(
|
|
'User' => $sEmail,
|
|
'Enable' => '1' === \trim($this->jsonParam('Enable', '0')),
|
|
'Secret' => $mData['Secret'],
|
|
'BackupCodes' => $mData['BackupCodes']
|
|
))
|
|
);
|
|
}
|
|
|
|
return $this->jsonResponse(__FUNCTION__, $bResult);
|
|
}
|
|
|
|
public function DoVerifyTwoFactorCode() : array
|
|
{
|
|
$oAccount = $this->getMainAccountFromToken();
|
|
|
|
if (!$this->TwoFactorAuthProvider($oAccount)) {
|
|
return $this->jsonResponse(__FUNCTION__, false);
|
|
}
|
|
|
|
$sCode = \trim($this->jsonParam('Code', ''));
|
|
|
|
$aData = $this->getTwoFactorInfo($oAccount);
|
|
$sSecret = !empty($aData['Secret']) ? $aData['Secret'] : '';
|
|
|
|
return $this->jsonResponse(__FUNCTION__,
|
|
$this->TwoFactorAuthProvider($oAccount)->VerifyCode($sSecret, $sCode));
|
|
}
|
|
|
|
public function DoClearTwoFactorInfo() : array
|
|
{
|
|
$oAccount = $this->getMainAccountFromToken();
|
|
|
|
if (!$this->TwoFactorAuthProvider($oAccount)) {
|
|
return $this->jsonResponse(__FUNCTION__, false);
|
|
}
|
|
|
|
$this->requireCurrentCode($oAccount);
|
|
|
|
$this->StorageProvider()->Clear($oAccount,
|
|
\RainLoop\Providers\Storage\Enumerations\StorageType::CONFIG,
|
|
'two_factor'
|
|
);
|
|
|
|
return $this->jsonResponse(__FUNCTION__, $this->getTwoFactorInfo($oAccount, true));
|
|
}
|
|
|
|
protected function Logger() : \MailSo\Log\Logger
|
|
{
|
|
return $this->Manager()->Actions()->Logger();
|
|
}
|
|
protected function getMainAccountFromToken() : MainAccount
|
|
{
|
|
return $this->Manager()->Actions()->getMainAccountFromToken();
|
|
}
|
|
protected function StorageProvider() : \RainLoop\Providers\Storage
|
|
{
|
|
return $this->Manager()->Actions()->StorageProvider();
|
|
}
|
|
|
|
private $oTwoFactorAuthProvider = null;
|
|
protected function TwoFactorAuthProvider(MainAccount $oAccount) : ?TwoFactorAuthInterface
|
|
{
|
|
if (!$this->oTwoFactorAuthProvider) {
|
|
require __DIR__ . '/providers/interface.php';
|
|
require __DIR__ . '/providers/totp.php';
|
|
$this->oTwoFactorAuthProvider = new TwoFactorAuthTotp();
|
|
}
|
|
return $this->oTwoFactorAuthProvider;
|
|
}
|
|
|
|
protected function getTwoFactorInfo(MainAccount $oAccount, bool $bRemoveSecret = false) : array
|
|
{
|
|
$sEmail = $oAccount->Email();
|
|
|
|
$mData = null;
|
|
|
|
$aResult = array(
|
|
'User' => '',
|
|
'IsSet' => false,
|
|
'Enable' => false,
|
|
'Secret' => '',
|
|
'BackupCodes' => ''
|
|
);
|
|
|
|
if (!empty($sEmail)) {
|
|
$aResult['User'] = $sEmail;
|
|
|
|
$sData = $this->StorageProvider()->Get($oAccount,
|
|
\RainLoop\Providers\Storage\Enumerations\StorageType::CONFIG,
|
|
'two_factor'
|
|
);
|
|
|
|
if ($sData) {
|
|
$mData = static::DecodeKeyValues($sData);
|
|
}
|
|
}
|
|
|
|
if (!empty($aResult['User']) &&
|
|
!empty($mData['User']) && !empty($mData['Secret']) &&
|
|
!empty($mData['BackupCodes']) && $sEmail === $mData['User'])
|
|
{
|
|
$aResult['IsSet'] = true;
|
|
$aResult['Enable'] = isset($mData['Enable']) ? !!$mData['Enable'] : false;
|
|
$aResult['Secret'] = $mData['Secret'];
|
|
$aResult['BackupCodes'] = $mData['BackupCodes'];
|
|
$aResult['QRCode'] = static::getQRCode($oAccount, $mData['Secret']);
|
|
}
|
|
|
|
if ($bRemoveSecret) {
|
|
if (isset($aResult['Secret'])) {
|
|
unset($aResult['Secret']);
|
|
}
|
|
|
|
if (isset($aResult['BackupCodes'])) {
|
|
unset($aResult['BackupCodes']);
|
|
}
|
|
}
|
|
|
|
return $aResult;
|
|
}
|
|
|
|
protected function removeBackupCodeFromTwoFactorInfo(MainAccount $oAccount, string $sCode) : bool
|
|
{
|
|
if (!$oAccount || empty($sCode)) {
|
|
return false;
|
|
}
|
|
|
|
$sData = $this->StorageProvider()->Get($oAccount,
|
|
\RainLoop\Providers\Storage\Enumerations\StorageType::CONFIG,
|
|
'two_factor'
|
|
);
|
|
|
|
if ($sData) {
|
|
$mData = static::DecodeKeyValues($sData);
|
|
|
|
if (!empty($mData['BackupCodes'])) {
|
|
$sBackupCodes = \preg_replace('/[^\d]+/', ' ', ' '.$mData['BackupCodes'].' ');
|
|
$sBackupCodes = \str_replace(' '.$sCode.' ', '', $sBackupCodes);
|
|
|
|
$mData['BackupCodes'] = \trim(\preg_replace('/[^\d]+/', ' ', $sBackupCodes));
|
|
|
|
return $this->StorageProvider()->Put($oAccount,
|
|
\RainLoop\Providers\Storage\Enumerations\StorageType::CONFIG,
|
|
'two_factor',
|
|
\json_encode($mData)
|
|
);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static function DecodeKeyValues(string $sData) : array
|
|
{
|
|
if (!\str_contains($sData, 'User')) {
|
|
$sData = \MailSo\Base\Utils::UrlSafeBase64Decode($sData);
|
|
if (!\strlen($sData)) {
|
|
return '';
|
|
}
|
|
$sKey = \md5(APP_SALT);
|
|
$sData = \is_callable('xxtea_decrypt')
|
|
? \xxtea_decrypt($sData, $sKey)
|
|
: \MailSo\Base\Xxtea::decrypt($sData, $sKey);
|
|
}
|
|
try {
|
|
return \json_decode($sData, true, 512, JSON_THROW_ON_ERROR) ?: array();
|
|
} catch (\Throwable $e) {
|
|
return \unserialize($sData) ?: array();
|
|
}
|
|
}
|
|
}
|